diff --git a/composer.json b/composer.json index d4ea997..d93da71 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "laravel/lumen", - "version": "4.10.0", + "version": "4.11.0", "description": "The Laravel Lumen Framework.", "keywords": [ "framework", @@ -20,7 +20,9 @@ "dingo/api": "^3.0", "doctrine/orm": "2.13.*", "doctrine/persistence": "^2.2", + "elasticsearch/elasticsearch": "^8.0", "facile-it/doctrine-mysql-come-back": "^1.10", + "freyo/flysystem-qcloud-cos-v5": "^2.0", "gedmo/doctrine-extensions": "^3.1", "gregwar/captcha": "^1.1", "hashids/hashids": "^4.1", @@ -36,10 +38,13 @@ "milon/barcode": "^8.0", "onex/espier-swoole": "^2.0.0", "overtrue/laravel-filesystem-qiniu": "^1.0", + "overtrue/pinyin": "^4.1", + "overtrue/socialite": "4.14.2", "paypal/paypal-server-sdk": "1.1.0", "predis/predis": "^1.1", "sentry/sentry-laravel": "^2.8", "shopex/prism": "^3.0", + "swiftmailer/swiftmailer": "^6.0", "symfony/process": "^5.3", "symfony/property-access": "^5.3", "tymon/jwt-auth": "^1.0", @@ -47,11 +52,7 @@ "w7corp/easywechat": "^5.0", "yansongda/pay": "^2.6", "yly-openapi/yly-openapi-sdk": "^1.0", - "yurunsoft/yurun-oauth-login": "^3.0", - "freyo/flysystem-qcloud-cos-v5": "^2.0", - "elasticsearch/elasticsearch": "^8.0", - "swiftmailer/swiftmailer": "^6.0", - "overtrue/pinyin": "^4.1" + "yurunsoft/yurun-oauth-login": "^3.0" }, "require-dev": { "brainmaestro/composer-git-hooks": "^2.8", @@ -119,7 +120,7 @@ "repositories": { "packagist": { "type": "composer", - "url": "https://mirrors.aliyun.com/composer/" + "url": "https://repo.packagist.org" }, "0": { "type": "composer", diff --git a/config/trustlogin.php b/config/trustlogin.php index bd1deff..5d9c66f 100644 --- a/config/trustlogin.php +++ b/config/trustlogin.php @@ -8,6 +8,7 @@ return [ 'secret' => "", 'name' => '微信', // 所有类型配置必须字段,前端可编辑 'status' => 'false', // 所有类型配置必须字段,前端不可编辑 + 'extra_config' => '', ] ], 'touch' => [ @@ -17,6 +18,39 @@ return [ 'secret' => "", 'name' => '微信', 'status' => 'false', - ] + 'extra_config' => '', + ], + [ + 'type' => 'apple', + 'app_id' => "", + 'secret' => "", + 'name' => 'Apple', + 'status' => 'false', + 'extra_config' => '', + ], + [ + 'type' => 'google', + 'app_id' => "", + 'secret' => "", + 'name' => 'Google', + 'status' => 'false', + 'extra_config' => '', + ], + [ + 'type' => 'facebook', + 'app_id' => "", + 'secret' => "", + 'name' => 'Facebook', + 'status' => 'false', + 'extra_config' => '', + ], + [ + 'type' => 'line', + 'app_id' => "", + 'secret' => "", + 'name' => 'Line', + 'status' => 'false', + 'extra_config' => '', + ], ], ]; diff --git a/database/migrations/Version20260731205807.php b/database/migrations/Version20260731205807.php new file mode 100644 index 0000000..f96eda3 --- /dev/null +++ b/database/migrations/Version20260731205807.php @@ -0,0 +1,27 @@ +abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE supplier_items_draft (draft_id BIGINT AUTO_INCREMENT NOT NULL COMMENT \'待审草稿ID\', source_item_id BIGINT NOT NULL COMMENT \'主表商品SKU ID\', goods_id BIGINT NOT NULL COMMENT \'SPU ID\', company_id BIGINT NOT NULL COMMENT \'公司ID\', supplier_id BIGINT DEFAULT 0 NOT NULL COMMENT \'供应商ID\', default_item_id BIGINT DEFAULT NULL COMMENT \'默认SKU主表ID\', is_default SMALLINT DEFAULT 0 NOT NULL COMMENT \'是否默认SKU\', content_json LONGTEXT NOT NULL COMMENT \'待审商品内容JSON\', created INT NOT NULL, updated INT DEFAULT NULL, INDEX ix_source_item_id (source_item_id), INDEX ix_goods_id (goods_id), INDEX ix_company_goods (company_id, goods_id), PRIMARY KEY(draft_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'供应商商品待审草稿表\' '); + $this->addSql('ALTER TABLE members_associations CHANGE unionid unionid VARCHAR(128) NOT NULL COMMENT \'第三方unionid\', CHANGE user_type user_type VARCHAR(30) NOT NULL COMMENT \'用户类型,可选值有 wechat:微信;ali:支付宝;apple;google;facebook;line\''); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema): void + { + } +} \ No newline at end of file diff --git a/routes/api/member.php b/routes/api/member.php index 483d45a..0c4977b 100644 --- a/routes/api/member.php +++ b/routes/api/member.php @@ -90,6 +90,7 @@ $api->version('v1', function($api) { //信任登录列表 $api->post('/members/trustlogin/list',['name'=>'获取信任登录列表','middleware'=>'activated', 'as' => 'member.trustlogin.list', 'uses' =>'TrustLogin@getTrustLoginList']); $api->put('/members/trustlogin/setting',['name'=>'保存信任登录状态','middleware'=>'activated', 'as' => 'member.trustlogin.setting', 'uses' =>'TrustLogin@saveStatusSetting']); + $api->post('/members/trustlogin/setting',['name'=>'保存信任登录状态POST','middleware'=>'activated', 'as' => 'member.trustlogin.setting.post', 'uses' =>'TrustLogin@saveStatusSetting']); //标签组相关路由 diff --git a/routes/frontapi/member.php b/routes/frontapi/member.php index fd5deca..b8a5a38 100644 --- a/routes/frontapi/member.php +++ b/routes/frontapi/member.php @@ -122,6 +122,8 @@ $api->version('v1', function ($api) { $api->post('/wxapp/member/bind', ['as' => 'front.h5app.member.bindMember', 'uses' => 'Members@bindMember']); }); $api->group(['prefix' => 'h5app', 'namespace' => 'MembersBundle\Http\FrontApi\V1\Action'], function ($api) { + // Apple form_post 回调:外部 POST 无 company_id/Origin,不能走 frontnoauth 中间件 + $api->match(['get', 'post'], '/wxapp/trustlogin/apple/callback', ['as' => 'front.h5app.trustlogin.apple.callback', 'uses' => 'TrustLogin@appleOAuthCallback', 'name' => 'Apple OAuth form_post 回调']); // 获取地区json $api->get('/wxapp/member/addressarea', ['as' => 'front.h5app.member.address.area', 'uses' => 'Members@getAddressArea']); $api->get('/wxapp/member/decryptPhone', ['as' => 'front.wxapp.member.decryptPhone', 'uses' => 'Members@getNoAuthDecryptPhoneNumber']); diff --git a/src/CompanysBundle/Http/Api/V1/Action/EmployeeController.php b/src/CompanysBundle/Http/Api/V1/Action/EmployeeController.php index 3529e81..c51681b 100644 --- a/src/CompanysBundle/Http/Api/V1/Action/EmployeeController.php +++ b/src/CompanysBundle/Http/Api/V1/Action/EmployeeController.php @@ -601,7 +601,7 @@ class EmployeeController extends BaseController */ public function getListData(Request $request) { - $params = $request->all('operator_id', 'mobile', 'username', 'role_id', 'page', 'pageSize', 'login_name', 'payment_method','staff_type', 'distributor_id', 'is_disable', 'supplier_name'); + $params = $request->all('operator_id', 'mobile', 'username', 'role_id', 'page', 'pageSize', 'login_name', 'payment_method','staff_type', 'distributor_id', 'is_disable', 'supplier_name', 'shop_code', 'shop_name'); $filter['company_id'] = app('auth')->user()->get('company_id'); $merchantId = app('auth')->user()->get('merchant_id'); $operatorType = app('auth')->user()->get('operator_type'); @@ -694,6 +694,51 @@ class EmployeeController extends BaseController } } + // 按店铺号 shop_code / 店铺名称 shop_name 筛选员工(仅 operator_type=distributor 等关联店铺账号场景常用) + // 流程:店铺表模糊查 distributor_id → 转成 operators.distributor_ids JSON 匹配串 → 筛员工列表 + $shopCode = trim((string) $request->input('shop_code', '')); + $shopName = trim((string) $request->input('shop_name', '')); + if ($shopCode !== '' || $shopName !== '') { + $distributorFilter = ['company_id' => $filter['company_id']]; + if ($shopCode !== '') { + $distributorFilter['shop_code|contains'] = $shopCode; // 店铺号模糊匹配 + } + if ($shopName !== '') { + $distributorFilter['name|contains'] = $shopName; // 店铺名称模糊匹配(店铺表 name 字段) + } + // 商户账号登录时,只在当前商户下的店铺里搜(用 $merchantId,因配送员分支可能已 unset filter['merchant_id']) + if ($merchantId > 0 && in_array($operatorType, ['merchant', 'staff', 'admin'])) { + $distributorFilter['merchant_id'] = $merchantId; + } + $distributorRepository = app('registry')->getManager('default')->getRepository(Distributor::class); + $distributors = $distributorRepository->getLists($distributorFilter, 'distributor_id'); + $matchedDistributorIds = array_column($distributors ?: [], 'distributor_id'); + // 若同时有 distributor_id(店铺后台或参数传入),与店铺筛选结果取交集 + if ($distributor_id) { + $matchedDistributorIds = array_values(array_intersect( + $matchedDistributorIds, + [(int) $distributor_id] + )); + } + // 没有匹配的店铺 → 直接返回空列表,避免误查全部员工 + if (!$matchedDistributorIds) { + return $this->response->array([ + 'total_count' => 0, + 'list' => [], + 'datapass_block' => $request->get('x-datapass-block', 0), + 'filter' => $filter, + ]); + } + // 员工表 distributor_ids 存的是 JSON,需转成 contains 用的子串模式(见 distributorIdsJsonContainsPatterns) + $patterns = []; + foreach ($matchedDistributorIds as $matchedDistributorId) { + foreach ($this->distributorIdsJsonContainsPatterns($matchedDistributorId) as $pattern) { + $patterns[] = $pattern; + } + } + $filter['distributor_ids'] = array_values(array_unique($patterns)); + } + $page = $params['page'] ?: 1; $pageSize = $params['pageSize'] ?: 20; $orderBy = ["created" => "DESC"]; diff --git a/src/CompanysBundle/Services/EmployeeService.php b/src/CompanysBundle/Services/EmployeeService.php index 8747103..69ee7e0 100644 --- a/src/CompanysBundle/Services/EmployeeService.php +++ b/src/CompanysBundle/Services/EmployeeService.php @@ -25,6 +25,7 @@ use CompanysBundle\Entities\Roles; use CompanysBundle\Jobs\EmployeeJob; use DistributionBundle\Entities\SelfDeliveryStaff; +use DistributionBundle\Services\DistributorService; use MerchantBundle\Services\MerchantService; use Exception; @@ -237,11 +238,20 @@ class EmployeeService } } + $distributorService = new DistributorService(); foreach ($operator['list'] as &$value) { $value['role_data'] = $this->getRoleData($value['company_id'], $value['operator_id']); if(isset($selfDeliveryStaffs[$value['operator_id']])){ $value = array_merge($value,$selfDeliveryStaffs[$value['operator_id']]); } + // 列表返回:为 distributor_ids 每项补充 shop_code(店铺号)、distributor_name(店铺名) + if ($value['distributor_ids']) { + $distributorService->getListAddDistributorFields( + $value['company_id'], + array_column($value['distributor_ids'], 'distributor_id'), + $value['distributor_ids'] + ); + } } return $operator; } diff --git a/src/EspierBundle/Auth/Jwt/EspierLocalUserProvider.php b/src/EspierBundle/Auth/Jwt/EspierLocalUserProvider.php index 03a79ca..d4a51bf 100644 --- a/src/EspierBundle/Auth/Jwt/EspierLocalUserProvider.php +++ b/src/EspierBundle/Auth/Jwt/EspierLocalUserProvider.php @@ -23,6 +23,7 @@ use Dingo\Api\Exception\StoreResourceFailedException; use MembersBundle\Repositories\MembersInfoRepository; use MembersBundle\Repositories\MembersRepository; use MembersBundle\Services\MemberService; +use MembersBundle\Services\MemberSyntheticMobileService; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Illuminate\Contracts\Auth\UserProvider; use Illuminate\Contracts\Auth\Authenticatable as UserContract; @@ -37,6 +38,8 @@ use MembersBundle\Services\UserService; use MembersBundle\Services\WechatUserService; use MembersBundle\Services\MemberEmailVerificationService; use MembersBundle\Services\MemberRegSettingService; +use MembersBundle\Services\SocialTrustLoginService; +use MembersBundle\Services\TrustLoginService; use WechatBundle\Services\OfficialAccountService; use WechatBundle\Services\OpenPlatform; @@ -130,6 +133,9 @@ class EspierLocalUserProvider implements UserProvider case "aliapp": $user = $this->preAliMiniAppLogin($credentials); break; + case "social_oauth": + $user = $this->preSocialOAuthLogin($credentials); + break; default: $user = []; } @@ -641,6 +647,113 @@ class EspierLocalUserProvider implements UserProvider return $result; } + private function preSocialOAuthLogin($inputData) + { + $companyId = (int)($inputData['company_id'] ?? 0); + $trustloginTag = (string)($inputData['trustlogin_tag'] ?? ''); + $code = (string)($inputData['code'] ?? ''); + $versionTag = (string)($inputData['version_tag'] ?? 'touch'); + + if (!$companyId || $trustloginTag === '' || $code === '') { + throw new ResourceException('缺少参数!'); + } + + $socialService = new SocialTrustLoginService(); + if (!$socialService->isSocialProvider($trustloginTag)) { + throw new ResourceException('不支持的第三方登录方式'); + } + + $configRow = (new TrustLoginService())->getConfigRow($trustloginTag, $versionTag, $companyId); + if (empty($configRow) || !($configRow['status'] === true || $configRow['status'] === 'true' || $configRow['status'] === 1 || $configRow['status'] === '1')) { + throw new ResourceException('该登录方式未开启'); + } + + $h5Host = $socialService->resolveH5Host(['origin' => $inputData['origin'] ?? '']); + if ($h5Host === '') { + throw new ResourceException('缺少 H5 域名配置'); + } + + $redirectUri = $socialService->buildRedirectUri($h5Host, $trustloginTag); + $socialUser = $socialService->resolveUserFromCode($trustloginTag, $configRow, $code, $redirectUri); + $unionid = $socialUser['unionid']; + $userType = $socialUser['user_type']; + + $membersAssociationsRepository = app('registry')->getManager('default')->getRepository(MembersAssociations::class); + $assoc = $membersAssociationsRepository->get([ + 'company_id' => $companyId, + 'user_type' => $userType, + 'unionid' => $unionid, + ]); + + $result = [ + 'id' => '0_espier_' . $trustloginTag . '_espier_' . $unionid, + 'user_id' => 0, + 'disabled' => 0, + 'company_id' => $companyId, + 'unionid' => $unionid, + 'openid' => $trustloginTag, + 'nickname' => $socialUser['nickname'] ?? '', + 'mobile' => '', + 'username' => $socialUser['nickname'] ?? '', + 'sex' => 0, + 'user_card_code' => '', + 'offline_card_code' => '', + 'operator_type' => 'user', + 'user_type' => $userType, + 'trustlogin_tag' => $trustloginTag, + 'is_new' => 1, + ]; + + if (!empty($assoc['user_id'])) { + $memberInfo = $this->getMemberInfo(['user_id' => $assoc['user_id'], 'company_id' => $companyId]); + if (!empty($memberInfo['user_id'])) { + $result = array_merge($result, [ + 'id' => $memberInfo['user_id'] . '_espier_companyid_espier_' . $companyId, + 'user_id' => $memberInfo['user_id'], + 'disabled' => $memberInfo['disabled'] ?? 0, + 'mobile' => $memberInfo['mobile'] ?? '', + 'user_card_code' => $memberInfo['user_card_code'] ?? '', + 'offline_card_code' => $memberInfo['offline_card_code'] ?? '', + 'is_new' => 0, + ]); + } + } else { + // 与邮箱注册一致:分配占位手机号并直接建会员,不强绑真实手机 + $mobile = (new MemberSyntheticMobileService())->allocateUnique($companyId); + $nickname = trim((string)($socialUser['nickname'] ?? '')); + $memberInfo = $this->memberService->createMember([ + 'mobile' => $mobile, + 'region_mobile' => $mobile, + 'mobile_country_code' => '86', + 'company_id' => $companyId, + 'wxa_appid' => '', + 'authorizer_appid' => '', + 'sex' => 0, + 'username' => $nickname !== '' ? $nickname : randValue(8), + 'avatar' => (string)($socialUser['avatar'] ?? ''), + 'email' => (string)($socialUser['email'] ?? ''), + 'api_from' => 'h5app', + 'auth_type' => 'social_oauth', + 'user_type' => $userType, + 'unionid' => $unionid, + 'open_id' => $unionid, + 'force_password' => 0, + ], false); + + $result = array_merge($result, [ + 'id' => $memberInfo['user_id'] . '_espier_companyid_espier_' . $companyId, + 'user_id' => $memberInfo['user_id'], + 'disabled' => $memberInfo['disabled'] ?? 0, + 'mobile' => $memberInfo['mobile'] ?? '', + 'user_card_code' => $memberInfo['user_card_code'] ?? '', + 'offline_card_code' => $memberInfo['offline_card_code'] ?? '', + 'is_new' => 0, + ]); + } + + return $result; + } + private function preAliMiniAppLogin($inputData) { if (empty($inputData['company_id'])) { diff --git a/src/EspierBundle/Http/FrontApi/V1/Action/LoginController.php b/src/EspierBundle/Http/FrontApi/V1/Action/LoginController.php index e5f6689..4f5942a 100644 --- a/src/EspierBundle/Http/FrontApi/V1/Action/LoginController.php +++ b/src/EspierBundle/Http/FrontApi/V1/Action/LoginController.php @@ -132,6 +132,13 @@ class LoginController extends Controller 'alipay_user_id' => $preLoginInfo['alipay_user_id'], ]; break; + case 'social_oauth': + $credentials = array_merge($request->input(), [ + 'company_id' => $companyId, + 'auth_type' => 'social_oauth', + 'origin' => app('request')->header('origin'), + ]); + break; default: throw new ResourceException('缺少参数,登录失败!'); } diff --git a/src/EspierBundle/Middleware/FrontNoAuthMiddleWare.php b/src/EspierBundle/Middleware/FrontNoAuthMiddleWare.php index 683649e..89124fa 100644 --- a/src/EspierBundle/Middleware/FrontNoAuthMiddleWare.php +++ b/src/EspierBundle/Middleware/FrontNoAuthMiddleWare.php @@ -40,6 +40,10 @@ class FrontNoAuthMiddleWare */ public function handle($request, Closure $next, $apiFrom) { + if ($apiFrom === 'h5app' && $this->isAppleTrustLoginCallback($request)) { + return $next($request); + } + $mid_params = []; if ($apiFrom == 'h5app') { config(['auth.defaults.guard' => 'h5api']); @@ -141,6 +145,14 @@ class FrontNoAuthMiddleWare return $next($request); } + private function isAppleTrustLoginCallback($request): bool + { + $path = ltrim((string) $request->path(), '/'); + + return str_ends_with($path, 'wxapp/trustlogin/apple/callback') + || str_contains($path, 'trustlogin/apple/callback'); + } + /** * Get the user from sessionval * diff --git a/src/MembersBundle/Entities/MembersAssociations.php b/src/MembersBundle/Entities/MembersAssociations.php index f705a60..141fa3d 100644 --- a/src/MembersBundle/Entities/MembersAssociations.php +++ b/src/MembersBundle/Entities/MembersAssociations.php @@ -43,7 +43,7 @@ class MembersAssociations * @var integer * * @ORM\Id - * @ORM\Column(name="unionid", type="string", length=40, options={"comment":"第三方unionid"}) + * @ORM\Column(name="unionid", type="string", length=128, options={"comment":"第三方unionid"}) */ private $unionid; @@ -59,7 +59,7 @@ class MembersAssociations * @var string * * @ORM\Id - * @ORM\Column(name="user_type", type="string", length=30, options={"comment":"用户类型,可选值有 wechat:微信;ali:支付宝"}) + * @ORM\Column(name="user_type", type="string", length=30, options={"comment":"用户类型,可选值有 wechat:微信;ali:支付宝;apple;google;facebook;line"}) */ private $user_type; diff --git a/src/MembersBundle/Http/Api/V1/Action/TrustLogin.php b/src/MembersBundle/Http/Api/V1/Action/TrustLogin.php index 8ada7f0..0f5c33d 100644 --- a/src/MembersBundle/Http/Api/V1/Action/TrustLogin.php +++ b/src/MembersBundle/Http/Api/V1/Action/TrustLogin.php @@ -20,6 +20,7 @@ namespace MembersBundle\Http\Api\V1\Action; use Illuminate\Http\Request; use App\Http\Controllers\Controller as Controller; use MembersBundle\Services\TrustLoginService; +use MembersBundle\Services\SocialTrustLoginService; class TrustLogin extends Controller { @@ -74,9 +75,17 @@ class TrustLogin extends Controller $params = $request->input(); $companyId = app('auth')->user()->get('company_id'); - $result = []; $result = $this->trustLoginService->getTrustLoginList($companyId); - // $result = array_values($result); + $socialService = new SocialTrustLoginService(); + foreach (['standard', 'touch'] as $version) { + if (!isset($result[$version])) { + continue; + } + foreach ($result[$version] as &$row) { + $row = $socialService->sanitizeConfigRow($row); + } + unset($row); + } return $this->response->array($result); } @@ -101,6 +110,7 @@ class TrustLogin extends Controller * @SWG\Parameter( name="secret", in="query", description="secret", required=true, type="string"), * @SWG\Parameter( name="loginversion", in="query", description="配置版本:标准版 standard 触屏版 touch", required=true, type="string"), * @SWG\Parameter( name="status", in="query", description="启用状态:启用 true 关闭 false", required=true, type="string"), + * @SWG\Parameter( name="extra_config", in="query", description="扩展配置 JSON(可选,Apple 等)", required=false, type="string"), * @SWG\Response( response=200, description="成功返回结构", @SWG\Schema( * @SWG\Property( property="data", type="string", example="true", description=""), * )), @@ -111,7 +121,8 @@ class TrustLogin extends Controller { $params = $request->input(); $companyId = app('auth')->user()->get('company_id'); - $params['status'] = ($params['status'] ?? false) === true; + $status = $params['status'] ?? false; + $params['status'] = $status === true || $status === 'true' || $status === 1 || $status === '1'; $result = $this->trustLoginService->saveStatusSetting($params, $companyId); return $this->response->array(['data' => $result]); diff --git a/src/MembersBundle/Http/FrontApi/V1/Action/TrustLogin.php b/src/MembersBundle/Http/FrontApi/V1/Action/TrustLogin.php index a1b747a..79583d9 100644 --- a/src/MembersBundle/Http/FrontApi/V1/Action/TrustLogin.php +++ b/src/MembersBundle/Http/FrontApi/V1/Action/TrustLogin.php @@ -24,6 +24,7 @@ use Illuminate\Http\Response; use MembersBundle\Services\MemberService; +use MembersBundle\Services\SocialTrustLoginService; use MembersBundle\Services\TrustLoginService; class TrustLogin extends Controller @@ -133,9 +134,42 @@ class TrustLogin extends Controller $authInfo = $request->get('auth'); $companyId = $authInfo['company_id']; $version_tag = $request->get('version_tag', 'standard'); - $result = []; + $socialService = new SocialTrustLoginService(); $data = $this->trustLoginService->getTrustLoginList($companyId); - $result = !empty($data) ? $data[$version_tag] : []; + $result = !empty($data) ? ($data[$version_tag] ?? []) : []; + foreach ($result as &$row) { + $row = $socialService->sanitizeConfigRow($row, true); + } + unset($row); return $this->response->array($result); } + + /** + * Apple Sign in with Apple 使用 form_post,授权结果 POST 到此接口后 302 回 H5(静态 nginx 无法直接接收 POST)。 + */ + public function appleOAuthCallback(Request $request) + { + $socialService = new SocialTrustLoginService(); + $h5Host = $socialService->resolveAppleCallbackH5Host([ + 'h5_host' => (string) $request->query('h5_host', ''), + 'state' => (string) $request->input('state', ''), + ]); + if ($h5Host === '') { + return response('Missing H5_BASE_URL configuration', 500); + } + + if ($request->filled('error')) { + return redirect($socialService->buildAppleH5LandingUrl($h5Host, '', [ + 'error' => (string) $request->input('error'), + 'error_description' => (string) $request->input('error_description', ''), + ])); + } + + $code = $socialService->normalizeOAuthCode((string) $request->input('code', '')); + if ($code === '') { + return response('Missing authorization code', 400); + } + + return redirect($socialService->buildAppleH5LandingUrl($h5Host, $code)); + } } diff --git a/src/MembersBundle/Services/MemberService.php b/src/MembersBundle/Services/MemberService.php index 89bbcc7..27008cf 100644 --- a/src/MembersBundle/Services/MemberService.php +++ b/src/MembersBundle/Services/MemberService.php @@ -227,9 +227,15 @@ class MemberService // 微信来源的用户,如果force_password不为1,会默认生成随机密码 // H5微信授权登录后,新用户需要手动输入密码才能创建用户 $forcePassword = (int)($params["force_password"] ?? 0); - if (($params['api_from'] == 'wechat' || $params['auth_type'] == 'wxapp' || $params['auth_type'] == 'aliapp') && $forcePassword === 0) { + if ( + ($params['api_from'] == 'wechat' || $params['auth_type'] == 'wxapp' || $params['auth_type'] == 'aliapp' || $params['auth_type'] == 'social_oauth') + && $forcePassword === 0 + ) { $params['password'] = substr(str_shuffle('QWERTYUIOPASDFGHJKLZXCVBNM1234567890qwertyuiopasdfghjklzxcvbnm'), 5, 10); // 生成随机密码 } + if ($params['auth_type'] == 'social_oauth' && ($params['password'] ?? '') === '') { + $params['password'] = substr(str_shuffle('QWERTYUIOPASDFGHJKLZXCVBNM1234567890qwertyuiopasdfghjklzxcvbnm'), 5, 10); + } $memberInfo['password'] = password_hash($params['password'], PASSWORD_DEFAULT); $memberInfo['source_from'] = $params['source_from'] ?? "default"; @@ -345,7 +351,7 @@ class MemberService } //关联表 - if ($params['api_from'] == 'wechat' || $params['auth_type'] == 'wxapp' || $params['auth_type'] == 'wx_offiaccount' || $params['auth_type'] == 'aliapp') { // 本地注册会员则不用创建关联信息 + if ($params['api_from'] == 'wechat' || $params['auth_type'] == 'wxapp' || $params['auth_type'] == 'wx_offiaccount' || $params['auth_type'] == 'aliapp' || $params['auth_type'] == 'social_oauth') { // 本地注册会员则不用创建关联信息 $this->createMemberAssociations((int)$params['company_id'], (int)$result['user_id'], (string)$params['unionid'], $params['user_type'] ?? 'wechat'); } @@ -589,9 +595,15 @@ class MemberService // 微信来源的用户,如果force_password不为1,会默认生成随机密码 // H5微信授权登录后,新用户需要手动输入密码才能创建用户 $forcePassword = (int)($params["force_password"] ?? 0); - if (($params['api_from'] == 'wechat' || $params['auth_type'] == 'wxapp' || $params['auth_type'] == 'aliapp') && $forcePassword === 0) { + if ( + ($params['api_from'] == 'wechat' || $params['auth_type'] == 'wxapp' || $params['auth_type'] == 'aliapp' || $params['auth_type'] == 'social_oauth') + && $forcePassword === 0 + ) { $params['password'] = substr(str_shuffle('QWERTYUIOPASDFGHJKLZXCVBNM1234567890qwertyuiopasdfghjklzxcvbnm'), 5, 10); // 生成随机密码 } + if ($params['auth_type'] == 'social_oauth' && ($params['password'] ?? '') === '') { + $params['password'] = substr(str_shuffle('QWERTYUIOPASDFGHJKLZXCVBNM1234567890qwertyuiopasdfghjklzxcvbnm'), 5, 10); + } $memberInfo['password'] = password_hash($params['password'], PASSWORD_DEFAULT); $memberInfo['source_from'] = $params['source_from'] ?? "default"; @@ -690,7 +702,7 @@ class MemberService } //关联表 - if ($params['api_from'] == 'wechat' || $params['auth_type'] == 'wxapp' || $params['auth_type'] == 'wx_offiaccount' || $params['auth_type'] == 'aliapp') { // 本地注册会员则不用创建关联信息 + if ($params['api_from'] == 'wechat' || $params['auth_type'] == 'wxapp' || $params['auth_type'] == 'wx_offiaccount' || $params['auth_type'] == 'aliapp' || $params['auth_type'] == 'social_oauth') { // 本地注册会员则不用创建关联信息 $this->createMemberAssociations((int)$params['company_id'], (int)$result['user_id'], (string)$params['unionid'], $params['user_type'] ?? 'wechat'); } @@ -1030,7 +1042,7 @@ class MemberService // $result["requestFields"] = $requestFields; } if (is_array($result)) { - MemberSyntheticMobileService::stripPlaceholderMobileForEmailRegisteredMember($result); + MemberSyntheticMobileService::stripSyntheticMobileForShopApi($result); } return $result; } @@ -1879,7 +1891,7 @@ class MemberService isset($value['mobile']) and $result[$key]['mobile'] = fixeddecrypt($value['mobile']); isset($value['username']) and $result[$key]['username'] = fixeddecrypt($value['username']); isset($value['nickname']) and $result[$key]['nickname'] = fixeddecrypt($value['nickname']); - MemberSyntheticMobileService::stripPlaceholderMobileForEmailRegisteredMember($result[$key]); + MemberSyntheticMobileService::stripSyntheticMobileForShopApi($result[$key]); } } return $result; @@ -2558,7 +2570,13 @@ class MemberService } $result['list'] = $criteria->select($row)->execute()->fetchAll(); foreach ($result['list'] as &$listRow) { - MemberSyntheticMobileService::stripPlaceholderMobileForEmailRegisteredMember($listRow); + if (isset($listRow['mobile'])) { + $listRow['mobile'] = fixeddecrypt($listRow['mobile']); + } + if (isset($listRow['region_mobile'])) { + $listRow['region_mobile'] = fixeddecrypt($listRow['region_mobile']); + } + MemberSyntheticMobileService::stripSyntheticMobileForShopApi($listRow); } unset($listRow); @@ -2788,6 +2806,11 @@ class MemberService */ public function bindMember(array $params): ?string { + $userType = (string)($params['user_type'] ?? 'wechat'); + if (in_array($userType, SocialTrustLoginService::SOCIAL_TYPES, true)) { + return $this->bindSocialMember($params, $userType); + } + $companyId = (int)$params['company_id']; $mobile = (string)$params['username']; $unionid = (string)$params['union_id']; @@ -2887,6 +2910,92 @@ class MemberService } } + /** + * 第三方 OAuth 绑定会员(Apple/Google/Facebook/Line) + */ + private function bindSocialMember(array $params, string $userType): ?string + { + $companyId = (int)$params['company_id']; + $mobile = (string)$params['username']; + $unionid = (string)$params['union_id']; + $checkType = $params['check_type'] ?? ''; + $password = $params['password'] ?? ''; + + if (!empty($checkType) && !(new MemberRegSettingService())->checkSmsVcode($mobile, $companyId, $params['vcode'], $checkType)) { + throw new ResourceException(trans('MembersBundle/Members.sms_code_error_service')); + } + + $conn = app('registry')->getConnection('default'); + $conn->beginTransaction(); + try { + $userFilter = [ + "company_id" => $companyId, + "mobile" => fixedencrypt($mobile) + ]; + $userEntity = $this->membersRepository->findOneBy($userFilter); + if (!$userEntity) { + $userInfo = $this->createMember([ + "mobile" => $mobile, + "region_mobile" => $mobile, + "mobile_country_code" => "86", + "company_id" => $companyId, + "wxa_appid" => "", + "authorizer_appid" => "", + "sex" => 0, + "username" => (string)($params['nickname'] ?? ''), + "avatar" => (string)($params['avatar'] ?? ''), + "email" => "", + "password" => $password, + "api_from" => "h5app", + "auth_type" => "social_oauth", + "user_type" => $userType, + "unionid" => $unionid, + "open_id" => $unionid, + "force_password" => 0 + ]); + $userInfo["is_new"] = 1; + } else { + if (!empty($password) && !$this->checkPassword($password, $userEntity->getPassword())) { + throw new ResourceException(trans('MembersBundle/Members.account_or_password_error')); + } + $userInfo = $this->membersRepository->getDataByEntity($userEntity); + $userInfo["is_new"] = 0; + $this->createMemberAssociations($companyId, (int)$userInfo["user_id"], $unionid, $userType); + } + + $conn->commit(); + + $user = $this->getTokenData($userInfo); + + $protocols = (new ProtocolService($user['company_id']))->get([ProtocolService::TYPE_MEMBER_REGISTER, ProtocolService::TYPE_PRIVACY]); + $membersProtocolLogService = new MembersProtocolLogService(); + foreach ($protocols as $protocol) { + if (!isset($protocol['digest'])) { + continue; + } + $acceptLog = [ + 'company_id' => $user['company_id'], + 'user_id' => $user['user_id'], + 'digest' => $protocol['digest'], + ]; + $membersProtocolLogService->create($acceptLog); + } + + return app('auth')->guard('h5api')->login(new GenericUser($user)); + } catch (ResourceException $resourceException) { + $conn->rollback(); + throw $resourceException; + } catch (\Throwable $throwable) { + $conn->rollback(); + app("log")->info(sprintf("user_bind_social_member_error:%s", jsonEncode([ + "message" => $throwable->getMessage(), + "file" => $throwable->getFile(), + "line" => $throwable->getLine(), + ]))); + throw new ResourceException(trans('MembersBundle/Members.unknown_error')); + } + } + /** * 将用户信息与外部平台的信息做关联绑定 同一个user_type下是一对一的关系 * @param int $companyId 企业id diff --git a/src/MembersBundle/Services/MemberSyntheticMobileService.php b/src/MembersBundle/Services/MemberSyntheticMobileService.php index 751991c..28a112a 100644 --- a/src/MembersBundle/Services/MemberSyntheticMobileService.php +++ b/src/MembersBundle/Services/MemberSyntheticMobileService.php @@ -41,24 +41,24 @@ class MemberSyntheticMobileService } } + /** + * 店铺端会员列表/详情/导出:占位手机号不展示(与前台 H5 一致)。 + * 邮箱注册、社交 OAuth 自动注册等场景分配的 `10`/`199` 占位号均置空;真实手机号不受影响。 + */ + public static function stripSyntheticMobileForShopApi(array &$member): void + { + self::stripSyntheticMobileForFrontApi($member); + } + /** * 店铺端 / 导出等:已绑定登录邮箱且 mobile 为占位号时,不在接口结果中返回手机号(置空)。 * 有真实手机号的会员(即使填写了 login_email)不受影响。 + * + * @deprecated 请使用 {@see stripSyntheticMobileForShopApi},社交 OAuth 等无 login_email 的占位号也需隐藏 */ public static function stripPlaceholderMobileForEmailRegisteredMember(array &$member): void { - if (trim((string) ($member['login_email'] ?? '')) === '') { - return; - } - foreach (['mobile', 'region_mobile'] as $key) { - if (!array_key_exists($key, $member)) { - continue; - } - $v = (string) $member[$key]; - if ($v !== '' && self::isAllocatedSyntheticMobile($v)) { - $member[$key] = ''; - } - } + self::stripSyntheticMobileForShopApi($member); } /** diff --git a/src/MembersBundle/Services/SocialTrustLoginService.php b/src/MembersBundle/Services/SocialTrustLoginService.php new file mode 100644 index 0000000..6b95a04 --- /dev/null +++ b/src/MembersBundle/Services/SocialTrustLoginService.php @@ -0,0 +1,304 @@ +buildAppleApiCallbackUri(); + } + + $base = rtrim($h5Host, '/'); + // OAuth 平台要求 redirect_uri 固定可登记;redi_url 改由前端 sessionStorage 带回 + return sprintf('%s/subpages/auth/auth-social-loading?trustlogin_tag=%s', $base, urlencode($trustloginTag)); + } + + public function buildAppleApiCallbackUri(): string + { + $apiBase = rtrim((string) config('common.api_base_url'), '/'); + if ($apiBase === '') { + $apiBase = rtrim(trim((string) env('APP_URL', '')), '/') . '/api'; + } + + return $apiBase . '/h5app/wxapp/trustlogin/apple/callback'; + } + + public function buildAppleH5LandingUrl(string $h5Host, string $code = '', array $extra = []): string + { + $h5Host = rtrim(trim($h5Host), '/'); + if ($h5Host === '' || !preg_match('#^https?://#i', $h5Host)) { + throw new ResourceException('缺少 H5 域名配置'); + } + + $base = $h5Host . '/subpages/auth/auth-social-loading'; + $query = array_merge(['trustlogin_tag' => 'apple'], $extra); + if ($code !== '') { + $query['code'] = $code; + } + + return $base . '?' . http_build_query($query); + } + + public function resolveH5Host(array $data): string + { + $fromRequest = trim((string)($data['h5_host'] ?? $data['origin'] ?? '')); + if ($fromRequest !== '') { + return rtrim($fromRequest, '/'); + } + + return rtrim(trim((string)config('common.h5_base_url')), '/'); + } + + public function buildSocialiteConfig(string $trustloginTag, array $configRow, string $redirectUri): array + { + $provider = strtolower($trustloginTag); + $providerConfig = [ + 'client_id' => $configRow['app_id'] ?? '', + 'client_secret' => $configRow['secret'] ?? '', + 'redirect_uri' => $redirectUri, + ]; + + if ($provider === 'apple') { + $extra = $this->parseExtraConfig($configRow['extra_config'] ?? ''); + if (!empty($extra['team_id'])) { + $providerConfig['team_id'] = $extra['team_id']; + } + if (!empty($extra['key_id'])) { + $providerConfig['key_id'] = $extra['key_id']; + } + $privateKey = $this->normalizeApplePrivateKey((string) ($extra['private_key'] ?? '')); + if ($privateKey === '' || $privateKey === '***') { + throw new ResourceException('Apple 私钥未配置:请在后台信任登录中填写 extra_config.private_key(完整 .p8 PEM)后保存'); + } + if (empty($extra['team_id']) || empty($extra['key_id'])) { + throw new ResourceException('Apple 配置不完整:extra_config 需包含 team_id、key_id、private_key'); + } + $providerConfig['private_key'] = $privateKey; + // Apple 使用 JWT 作为 client_secret,忽略 secret 字段 + unset($providerConfig['client_secret']); + } + + return [$provider => $providerConfig]; + } + + public function getAuthorizeUrl(string $trustloginTag, array $configRow, string $redirectUri, string $h5Host = ''): string + { + $socialiteConfig = $this->buildSocialiteConfig($trustloginTag, $configRow, $redirectUri); + $socialite = new SocialiteManager($socialiteConfig); + $provider = $socialite->create(strtolower($trustloginTag)); + if ($trustloginTag === 'apple' && $h5Host !== '') { + $state = $this->encodeAppleOAuthState($h5Host); + $provider->withState($state); + $this->rememberAppleOAuthH5Host($state, $h5Host); + } + + return $provider->redirect($redirectUri); + } + + public function resolveAppleCallbackH5Host(array $data): string + { + $state = (string) ($data['state'] ?? ''); + $candidates = [ + (string) ($data['h5_host'] ?? ''), + $this->recallAppleOAuthH5Host($state), + (string) ($this->decodeAppleOAuthState($state)['h5_host'] ?? ''), + (string) env('H5_BASE_URL', ''), + ]; + foreach ($candidates as $candidate) { + $host = rtrim(trim($candidate), '/'); + if ($host !== '' && preg_match('#^https?://#i', $host)) { + return $host; + } + } + + return ''; + } + + public function rememberAppleOAuthH5Host(string $state, string $h5Host): void + { + $state = trim($state); + $h5Host = rtrim(trim($h5Host), '/'); + if ($state === '' || $h5Host === '') { + return; + } + + app('redis')->connection('default')->setex('ecx:apple_oauth:h5:' . md5($state), 600, $h5Host); + } + + public function recallAppleOAuthH5Host(string $state): string + { + $state = trim($state); + if ($state === '') { + return ''; + } + + $value = app('redis')->connection('default')->get('ecx:apple_oauth:h5:' . md5($state)); + + return is_string($value) ? rtrim(trim($value), '/') : ''; + } + + public function encodeAppleOAuthState(string $h5Host): string + { + $payload = json_encode(['h5_host' => rtrim(trim($h5Host), '/')], JSON_UNESCAPED_SLASHES); + + return rtrim(strtr(base64_encode((string) $payload), '+/', '-_'), '='); + } + + public function decodeAppleOAuthState(string $state): array + { + $state = trim($state); + if ($state === '') { + return []; + } + + $normalized = strtr($state, '-_', '+/'); + $padding = strlen($normalized) % 4; + if ($padding > 0) { + $normalized .= str_repeat('=', 4 - $padding); + } + $decoded = json_decode((string) base64_decode($normalized), true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * @return array{unionid: string, user_type: string, nickname: string, email: string, avatar: string} + */ + public function resolveUserFromCode(string $trustloginTag, array $configRow, string $code, string $redirectUri): array + { + if (!$this->isSocialProvider($trustloginTag)) { + throw new ResourceException('不支持的第三方登录方式'); + } + + $code = $this->normalizeOAuthCode($code); + if ($code === '') { + throw new ResourceException('授权码无效'); + } + + $socialiteConfig = $this->buildSocialiteConfig($trustloginTag, $configRow, $redirectUri); + $socialite = new SocialiteManager($socialiteConfig); + $user = $socialite->create(strtolower($trustloginTag))->userFromCode($code); + + $unionid = (string)$user->getId(); + if ($unionid === '') { + throw new ResourceException('第三方授权信息无效'); + } + + return [ + 'unionid' => $unionid, + 'user_type' => $trustloginTag, + 'nickname' => (string)($user->getNickname() ?? $user->getName() ?? ''), + 'email' => (string)($user->getEmail() ?? ''), + 'avatar' => (string)($user->getAvatar() ?? ''), + ]; + } + + public function sanitizeConfigRow(array $row, bool $forFront = false): array + { + if ($forFront) { + unset($row['secret']); + } + + if (!isset($row['extra_config']) || $row['extra_config'] === '') { + $row['extra_config'] = ''; + + return $row; + } + + $extra = $this->parseExtraConfig($row['extra_config']); + if ($forFront) { + unset($extra['private_key'], $extra['team_id'], $extra['key_id']); + } elseif (isset($extra['private_key'])) { + $extra['private_key'] = '***'; + } + $row['extra_config'] = $extra === [] ? '' : json_encode($extra, JSON_UNESCAPED_UNICODE); + + return $row; + } + + public function parseExtraConfig($extraConfig): array + { + if (is_array($extraConfig)) { + return $extraConfig; + } + if (!is_string($extraConfig) || trim($extraConfig) === '') { + return []; + } + + $decoded = json_decode($extraConfig, true); + if (is_string($decoded)) { + $nested = json_decode($decoded, true); + if (is_array($nested)) { + $decoded = $nested; + } + } + + return is_array($decoded) ? $decoded : []; + } + + public function normalizeApplePrivateKey(string $privateKey): string + { + $privateKey = trim($privateKey); + if ($privateKey === '' || $privateKey === '***') { + return $privateKey; + } + + $privateKey = str_replace(['\\n', "\r\n", "\r"], ["\n", "\n", "\n"], $privateKey); + if (strpos($privateKey, 'BEGIN PRIVATE KEY') === false) { + $body = preg_replace('/\s+/', '', $privateKey); + $privateKey = "-----BEGIN PRIVATE KEY-----\n" + . chunk_split((string) $body, 64, "\n") + . "-----END PRIVATE KEY-----"; + } + + return $privateKey; + } + + public function normalizeOAuthCode(string $code): string + { + $code = trim($code); + if ($code === '') { + return ''; + } + + $prev = null; + while ($prev !== $code) { + $prev = $code; + $decoded = rawurldecode($code); + if ($decoded === $code) { + break; + } + $code = $decoded; + } + + return $code; + } +} diff --git a/src/MembersBundle/Services/TrustLoginService.php b/src/MembersBundle/Services/TrustLoginService.php index 02f3c0b..305f138 100644 --- a/src/MembersBundle/Services/TrustLoginService.php +++ b/src/MembersBundle/Services/TrustLoginService.php @@ -17,9 +17,10 @@ namespace MembersBundle\Services; +use Dingo\Api\Exception\ResourceException; +use Overtrue\Socialite\SocialiteManager; use WechatBundle\Services\OfficialAccountService; use WechatBundle\Services\OpenPlatform; -use Overtrue\Socialite\SocialiteManager; class TrustLoginService { @@ -38,13 +39,17 @@ class TrustLoginService $keyStr = $this->key. $companyId; $redis = app('redis')->connection('default'); $result = $redis->get($keyStr); + $defaults = config('trustlogin'); if (empty($result)) { - $result = config('trustlogin'); + $result = $defaults; $redis->set($keyStr, json_encode($result)); } else { $result = json_decode($result, 1); + $result = $this->mergeTrustLoginConfig($result, $defaults); + $redis->set($keyStr, json_encode($result)); } + return $result; } @@ -59,6 +64,7 @@ class TrustLoginService */ public function trustLoginParams($companyId, $trustlogin_tag, $version_tag = 'standard', $data) { + $result = []; if ($version_tag == 'standard') { switch ($trustlogin_tag) { case 'weixin': @@ -72,8 +78,7 @@ class TrustLoginService ]; $socialite = new SocialiteManager($config); - $response = $socialite->driver('wechat')->redirect(); - $redirect_url = $response->getTargetUrl(); + $redirect_url = $socialite->create('wechat')->redirect($data['redirect_url'] ?? null); $result['config_info'] = $configInfo; $result['redirect_url'] = $redirect_url; break; @@ -86,7 +91,7 @@ class TrustLoginService switch ($trustlogin_tag) { case 'weixin': $result['oauth_url'] = ''; - $h5_host = config('common.h5_base_url') ?: $data['h5_host']; // 如果没有配置域名则默认使用原始请求域名 + $h5_host = (new SocialTrustLoginService())->resolveH5Host($data); $path = $data['redirect_url'] ? ('?redi_url='. $data['redirect_url']) : ''; $url = sprintf("%s/subpages/auth/auth-loading%s", $h5_host, $path); @@ -100,23 +105,23 @@ class TrustLoginService if ($oauthUrl = (new OfficialAccountService($app))->getAuthorizationUrl($url)) { $result['oauth_url'] = $oauthUrl; } -// $openPlatform = new OpenPlatform; -// $woa_appid = $openPlatform->getWoaAppidByCompanyId($companyId); -// //公众号授权模式 -// if (!empty($woa_appid)) { -// $app = $openPlatform->getAuthorizerApplication($woa_appid); -// $result['oauth_url'] = (new OfficialAccountService($app))->getAuthorizationUrl($url); -// } else { -// //普通填参模式 -// $configInfo = $this->getConfigRow($trustlogin_tag, $version_tag, $companyId); -// if (!empty($configInfo) && !empty($configInfo["status"])) { -// $app = app('easywechat.official_account', $configInfo); -// $result['oauth_url'] = (new OfficialAccountService($app))->getAuthorizationUrl($url); -// } -// } break; default: - $result = []; + $socialService = new SocialTrustLoginService(); + if ($socialService->isSocialProvider($trustlogin_tag)) { + $configInfo = $this->getConfigRow($trustlogin_tag, $version_tag, $companyId); + if (empty($configInfo) || !$this->isEnabled($configInfo['status'] ?? false)) { + throw new ResourceException('该登录方式未开启'); + } + $h5_host = $socialService->resolveH5Host($data); + if ($h5_host === '') { + throw new ResourceException('缺少 H5 域名配置'); + } + $redirectUri = $socialService->buildRedirectUri($h5_host, $trustlogin_tag); + $result['oauth_url'] = $socialService->getAuthorizeUrl($trustlogin_tag, $configInfo, $redirectUri, $h5_host); + } else { + $result = []; + } break; } } @@ -136,7 +141,8 @@ class TrustLoginService $redis = app('redis')->connection('default'); $result = $redis->get($keyStr); if (empty($result)) { - return false; + $result = $this->getTrustLoginList($companyId); + $result = json_encode($result); } $result = json_decode($result, 1); if (!isset($result[$data['loginversion']])) { @@ -145,11 +151,28 @@ class TrustLoginService $editVersion = $result[$data['loginversion']]; $rowResult = collect($editVersion)->firstWhere('type', $data['type']); + if (empty($rowResult)) { + return false; + } foreach ($rowResult as $key => &$value) { + if ($key === 'extra_config') { + continue; + } if (isset($data[$key])) { $value = $data[$key]; } } + if (array_key_exists('extra_config', $data)) { + $rowResult['extra_config'] = $this->mergeExtraConfigOnSave( + (string) ($rowResult['extra_config'] ?? ''), + $this->normalizeExtraConfig($data['extra_config']) + ); + if (($data['type'] ?? '') === 'apple') { + $this->assertAppleExtraConfigValid($rowResult['extra_config']); + } + } elseif (!isset($rowResult['extra_config'])) { + $rowResult['extra_config'] = ''; + } foreach ($editVersion as $k => &$config) { if ($config['type'] == $data['type']) { $config = $rowResult; @@ -163,20 +186,90 @@ class TrustLoginService public function getConfigRow($type, $version = 'standard', $companyId) { - $keyStr = $this->key. $companyId; - $redis = app('redis')->connection('default'); - $result = $redis->get($keyStr); - if (empty($result)) { + $list = $this->getTrustLoginList($companyId); + if (!isset($list[$version])) { return []; } - $result = json_decode($result, 1); - if (!isset($result[$version])) { - return []; + $editVersion = $list[$version]; + + return collect($editVersion)->firstWhere('type', $type) ?: []; + } + + private function mergeTrustLoginConfig(array $stored, array $defaults): array + { + foreach (['standard', 'touch'] as $version) { + if (!isset($defaults[$version])) { + continue; + } + $stored[$version] = $stored[$version] ?? []; + $storedTypes = array_column($stored[$version], 'type'); + foreach ($defaults[$version] as $defaultRow) { + if (!in_array($defaultRow['type'], $storedTypes, true)) { + $stored[$version][] = $defaultRow; + } + } + foreach ($stored[$version] as &$row) { + if (!isset($row['extra_config'])) { + $row['extra_config'] = ''; + } + } + unset($row); } - $editVersion = $result[$version]; - $rowResult = collect($editVersion)->firstWhere('type', $type); + return $stored; + } - return $rowResult; + private function normalizeExtraConfig($extraConfig): string + { + if ($extraConfig === null || $extraConfig === '') { + return ''; + } + if (is_array($extraConfig)) { + return json_encode($extraConfig, JSON_UNESCAPED_UNICODE); + } + + return trim((string)$extraConfig); + } + + private function mergeExtraConfigOnSave(string $existing, string $incoming): string + { + $existingExtra = json_decode($existing, true); + $incomingExtra = json_decode($incoming, true); + if (!is_array($incomingExtra)) { + return $existing !== '' ? $existing : $incoming; + } + if (!is_array($existingExtra)) { + $existingExtra = []; + } + foreach (['private_key', 'team_id', 'key_id'] as $field) { + if (!array_key_exists($field, $incomingExtra)) { + continue; + } + if ($incomingExtra[$field] === '***' && !empty($existingExtra[$field])) { + $incomingExtra[$field] = $existingExtra[$field]; + } + } + + return json_encode($incomingExtra, JSON_UNESCAPED_UNICODE); + } + + private function assertAppleExtraConfigValid(string $extraConfig): void + { + $extra = json_decode($extraConfig, true); + if (!is_array($extra)) { + throw new ResourceException('Apple extra_config 格式错误'); + } + $privateKey = trim((string) ($extra['private_key'] ?? '')); + if ($privateKey === '' || $privateKey === '***') { + throw new ResourceException('Apple 保存失败:请填写完整 private_key(.p8 PEM)'); + } + if (trim((string) ($extra['team_id'] ?? '')) === '' || trim((string) ($extra['key_id'] ?? '')) === '') { + throw new ResourceException('Apple 保存失败:extra_config 需包含 team_id 与 key_id'); + } + } + + private function isEnabled($status): bool + { + return $status === true || $status === 'true' || $status === 1 || $status === '1'; } } diff --git a/src/PopularizeBundle/Http/Api/V1/Action/BrokerageController.php b/src/PopularizeBundle/Http/Api/V1/Action/BrokerageController.php index ccee57b..ad27602 100644 --- a/src/PopularizeBundle/Http/Api/V1/Action/BrokerageController.php +++ b/src/PopularizeBundle/Http/Api/V1/Action/BrokerageController.php @@ -254,10 +254,21 @@ class BrokerageController extends Controller $companyId = app('auth')->user()->get('company_id'); + $params = $request->all('user_id', 'distributor_id'); + $rules = [ + 'user_id' => ['nullable|integer', 'user_id参数错误'], + 'distributor_id' => ['nullable|integer', 'distributor_id参数错误'], + ]; + $error = validator_params($params, $rules); + if ($error) { + throw new ResourceException($error); + } + $userId = $request->input('user_id'); $distributor_id = $request->input('distributor_id', 0); if ($distributor_id) { $brokerageService = new BrokerageService(); + $filter['company_id'] = $companyId; $filter['dIds'] = [$distributor_id]; $filter['user_id'] = $userId; $countDataShop = $brokerageService->getSalesmanBrokerageCount($filter, 1 ,1000); @@ -349,11 +360,13 @@ class BrokerageController extends Controller */ public function getBrokerageList(Request $request) { - $params = $request->all('pageSize', 'page'); + $params = $request->all('pageSize', 'page', 'user_id', 'distributor_id'); $rules = [ 'page' => ['required|integer|min:1','分页参数错误'], 'pageSize' => ['required|integer|min:1|max:50','每页最多查询50条数据'], + 'user_id' => ['nullable|integer', 'user_id参数错误'], + 'distributor_id' => ['nullable|integer', 'distributor_id参数错误'], ]; $error = validator_params($params, $rules); diff --git a/src/PopularizeBundle/Http/Api/V1/Action/PromoterController.php b/src/PopularizeBundle/Http/Api/V1/Action/PromoterController.php index ea4c1b7..1434360 100644 --- a/src/PopularizeBundle/Http/Api/V1/Action/PromoterController.php +++ b/src/PopularizeBundle/Http/Api/V1/Action/PromoterController.php @@ -97,6 +97,16 @@ class PromoterController extends Controller $promoterService = new PromoterService(); $companyId = app('auth')->user()->get('company_id'); + + $validateParams = $request->all('distributor_id'); + $validateRules = [ + 'distributor_id' => ['nullable|integer', 'distributor_id参数错误'], + ]; + $error = validator_params($validateParams, $validateRules); + if ($error) { + throw new ResourceException($error); + } + if ($request->input('mobile', null)) { $filter['mobile'] = $request->input('mobile'); } @@ -710,6 +720,16 @@ class PromoterController extends Controller //存储导出操作账号者 $operator_id = app('auth')->user()->get('operator_id'); $params['company_id'] = app('auth')->user()->get('company_id'); + + $validateParams = $request->all('distributor_id'); + $validateRules = [ + 'distributor_id' => ['nullable|integer', 'distributor_id参数错误'], + ]; + $error = validator_params($validateParams, $validateRules); + if ($error) { + throw new ResourceException($error); + } + if ($inputData['mobile'] ?? '') { $params['mobile'] = $inputData['mobile'] ?? ''; } @@ -787,6 +807,16 @@ class PromoterController extends Controller //存储导出操作账号者 $operator_id = app('auth')->user()->get('operator_id'); $params['company_id'] = app('auth')->user()->get('company_id'); + + $validateParams = $request->all('distributor_id'); + $validateRules = [ + 'distributor_id' => ['nullable|integer', 'distributor_id参数错误'], + ]; + $error = validator_params($validateParams, $validateRules); + if ($error) { + throw new ResourceException($error); + } + if ($inputData['mobile'] ?? '') { $params['mobile'] = $inputData['mobile'] ?? ''; } diff --git a/src/PopularizeBundle/Http/FrontApi/V1/Action/BrokerageController.php b/src/PopularizeBundle/Http/FrontApi/V1/Action/BrokerageController.php index c8a4dcd..b00f689 100644 --- a/src/PopularizeBundle/Http/FrontApi/V1/Action/BrokerageController.php +++ b/src/PopularizeBundle/Http/FrontApi/V1/Action/BrokerageController.php @@ -80,7 +80,18 @@ class BrokerageController extends Controller $filter['is_close'] = true; $isSalesmanPage = $request->input('isSalesmanPage', 0); - if($isSalesmanPage){ + if ($isSalesmanPage) { + $validateParams = $request->all('distributor_id', 'mobile', 'order_id'); + $validateRules = [ + 'distributor_id' => ['nullable|integer', 'distributor_id参数错误'], + 'mobile' => ['nullable|string', 'mobile参数错误'], + 'order_id' => ['nullable|string', 'order_id参数错误'], + ]; + $error = validator_params($validateParams, $validateRules); + if ($error) { + throw new ResourceException($error); + } + $shopName = $request->input('shopName', 0); $mobile = $request->input('mobile', 0); $order_id = $request->input('order_id', 0); @@ -157,6 +168,15 @@ class BrokerageController extends Controller $isSalesmanPage = $request->input('isSalesmanPage', 0); if($isSalesmanPage){ + $validateParams = $request->all('distributor_id'); + $validateRules = [ + 'distributor_id' => ['nullable|integer', 'distributor_id参数错误'], + ]; + $error = validator_params($validateParams, $validateRules); + if ($error) { + throw new ResourceException($error); + } + $distributor_id = $request->input('distributor_id', 0); $isSalesmanPage = $request->input('isSalesmanPage', 0); diff --git a/src/PopularizeBundle/Http/FrontApi/V1/Action/PromoterController.php b/src/PopularizeBundle/Http/FrontApi/V1/Action/PromoterController.php index 3ba8d7f..554e11d 100644 --- a/src/PopularizeBundle/Http/FrontApi/V1/Action/PromoterController.php +++ b/src/PopularizeBundle/Http/FrontApi/V1/Action/PromoterController.php @@ -996,8 +996,18 @@ class PromoterController extends Controller { $authInfo = $request->get('auth'); $inputData = $request->input(); + $inputData['company_id'] = $authInfo['company_id']; - $brokerageService = new BrokerageService(); + $validateParams = $request->all('date', 'distributor_id', 'datetype'); + $validateRules = [ + 'date' => ['nullable|string', 'date参数错误'], + 'distributor_id' => ['nullable|integer', 'distributor_id参数错误'], + 'datetype' => ['nullable|in:y,m,d', 'datetype参数错误'], + ]; + $error = validator_params($validateParams, $validateRules); + if ($error) { + throw new ResourceException($error); + } $promoterService = new PromoterService(); @@ -1154,7 +1164,18 @@ class PromoterController extends Controller { $authInfo = $request->get('auth'); $inputData = $request->input(); - $brokerageService = new BrokerageService(); + $inputData['company_id'] = $authInfo['company_id']; + + $validateParams = $request->all('date', 'distributor_id', 'datetype'); + $validateRules = [ + 'date' => ['nullable|string', 'date参数错误'], + 'distributor_id' => ['nullable|integer', 'distributor_id参数错误'], + 'datetype' => ['nullable|in:y,m,d', 'datetype参数错误'], + ]; + $error = validator_params($validateParams, $validateRules); + if ($error) { + throw new ResourceException($error); + } $promoterService = new PromoterService(); diff --git a/src/PopularizeBundle/Services/BrokerageService.php b/src/PopularizeBundle/Services/BrokerageService.php index 3408e84..9dcbf74 100644 --- a/src/PopularizeBundle/Services/BrokerageService.php +++ b/src/PopularizeBundle/Services/BrokerageService.php @@ -657,70 +657,92 @@ class BrokerageService } public function getSalesmanBrokeragelistsBySql($params, $limit, $page){ - $userId = $params['user_id'] ?? 0; - $start = $limit * ($page - 1); + $companyId = $params['company_id'] ?? null; + if (empty($companyId)) { + throw new ResourceException('company_id is required'); + } + $companyId = (int) $companyId; + + $userId = isset($params['user_id']) ? (int) $params['user_id'] : 0; + $start = (int) ($limit * ($page - 1)); + $limit = (int) $limit; + + $bindParams = ['company_id' => $companyId]; + $types = []; + $sqlWhereParts = [' AND bb.company_id = :company_id ']; - $sqlWhereShopId = ' '; - //is_close if(isset($params['is_close']) ){ $is_close = $params['is_close'] ? 1 : 0; - $sqlWhereIsClose = " and bb.is_close = {$is_close} "; + $bindParams['is_close'] = $is_close; + $sqlWhereParts[] = ' and bb.is_close = :is_close '; } if(isset($params['mobile']) && $params['mobile'] ){ - $sqlWhereIsMobile = " and oo.mobile = '{$params['mobile']}' "; + $bindParams['mobile'] = $params['mobile']; + $sqlWhereParts[] = ' and oo.mobile = :mobile '; } if(isset($params['order_id']) && $params['order_id'] ){ - $sqlWhereIsOrderId = " and bb.order_id = '{$params['order_id']}' "; + $bindParams['order_id'] = $params['order_id']; + $sqlWhereParts[] = ' and bb.order_id = :order_id '; } - if(isset($params['distributor_id']) ){ - $dIds_str = $params['distributor_id']; - $sqlWhereShopId = " and oo.distributor_id in ( {$dIds_str} ) "; + $dIds = []; + if (isset($params['dIds']) && is_array($params['dIds'])) { + $dIds = array_values(array_filter(array_map('intval', $params['dIds']), function ($v) { + return $v > 0; + })); + } elseif (isset($params['distributor_id']) && !empty($params['distributor_id'])) { + if (is_array($params['distributor_id'])) { + $dIds = array_values(array_filter(array_map('intval', $params['distributor_id']), function ($v) { + return $v > 0; + })); + } else { + $dIds = array_values(array_filter(array_map('intval', explode(',', (string) $params['distributor_id'])), function ($v) { + return $v > 0; + })); + } } - if(isset($params['dIds']) ){ - $dIds_str = implode($params['dIds'], ","); - $sqlWhereShopId = " and oo.distributor_id in ( {$dIds_str} ) "; + if ($dIds) { + $placeholders = []; + foreach ($dIds as $index => $dId) { + $key = 'distributor_id_' . $index; + $placeholders[] = ':' . $key; + $bindParams[$key] = $dId; + } + $sqlWhereParts[] = ' and oo.distributor_id in (' . implode(', ', $placeholders) . ') '; } - if(isset($params['date_start']) ){ - $date_str = strtotime($params['date_start']." 00:00:00"); - $sqlWhereStart = " and bb.created >= ( {$date_str} ) "; + $bindParams['date_start'] = strtotime($params['date_start']." 00:00:00"); + $sqlWhereParts[] = ' and bb.created >= :date_start '; } - + if(isset($params['date_end']) ){ - $date_str = strtotime($params['date_end']." 23:59:59"); - $sqlWhereEnd = " and bb.created <= ( {$date_str} ) "; + $bindParams['date_end'] = strtotime($params['date_end']." 23:59:59"); + $sqlWhereParts[] = ' and bb.created <= :date_end '; } $conn = app("registry")->getConnection('default'); - $qb = $conn->createQueryBuilder(); - $sql_total = "SELECT count(1) as total"; + $sql_total = "SELECT count(1) as total"; $sql_cls = "SELECT bb.*,oo.distributor_id,oo.title ,oo.total_fee ,ss.name, ss.mobile ,dd.name as store_name ,if(ss.user_id >0,'业务员','推广员') as promote_type ,ss.salesperson_id "; $countSql = " FROM popularize_brokerage as bb left join orders_normal_orders oo ON bb.order_id = oo.order_id left join aftersales as aa ON bb.order_id = aa.order_id left join shop_salesperson as ss ON bb.user_id = ss.user_id and oo.distributor_id = ss.shop_id left join distribution_distributor as dd ON oo.distributor_id = dd.distributor_id WHERE 1 and source = 'order' and ss.salesperson_id is not null "; - // $countSql .= $sqlWhereDate ; if($userId){ - $countSql .= " AND bb.user_id = {$userId} "; + $bindParams['user_id'] = $userId; + $countSql .= " AND bb.user_id = :user_id "; } - $countSql .= $sqlWhereShopId ?? ' '; - $countSql .= $sqlWhereIsClose ?? ' '; - $countSql .= $sqlWhereIsMobile ?? ' '; - $countSql .= $sqlWhereStart ?? ' '; - $countSql .= $sqlWhereEnd ?? ' '; - $countSql .= $sqlWhereIsOrderId ?? ' '; - // $countSql .= $sqlTab ?? ' '; + $countSql .= implode('', $sqlWhereParts); $countSql .= " order by bb.created desc "; - $countBrolage = $conn->executeQuery( $sql_total . $countSql)->fetch(); + $countBrolage = $conn->executeQuery($sql_total . $countSql, $bindParams, $types)->fetch(); - $countSql .= " limit {$start} , {$limit}"; - $conn = app('registry')->getConnection('default'); - app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-countSql:". json_encode($countSql)); + $listBindParams = $bindParams; + $listBindParams['offset'] = $start; + $listBindParams['limit'] = $limit; + $listSql = $countSql . " limit :offset , :limit"; + app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-countSql:". json_encode($listSql)); - - $listBrokerage = $conn->executeQuery($sql_cls . $countSql)->fetchAll(); - app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-listBrokerage:". json_encode($sql_cls . $countSql)); + $listBrokerage = $conn->executeQuery($sql_cls . $listSql, $listBindParams, $types)->fetchAll(); + app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-listBrokerage:". json_encode($sql_cls . $listSql)); app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-listBrokerage:". json_encode($listBrokerage)); // 统计推广员增加人数 // $listPromoter = $this->getSalesPromotersStatic($userId, $dateLen , $sqlWhereDate, $sqlWhereShopId ) ; @@ -733,7 +755,8 @@ class BrokerageService - $salesperson_id_arr = array_column($countDataShopList['list'],'salesperson_id'); + $salesperson_id_arr = array_column($countDataShopList['list'],'salesperson_id'); + $data_alesperson_id_arr = []; if($salesperson_id_arr){ $filter_salesperson = array( 'salesperson_id' => $salesperson_id_arr, @@ -741,7 +764,7 @@ class BrokerageService $salespersonService = new SalespersonService(); $data_alesperson = $salespersonService->salesperson->lists($filter_salesperson); - $data_alesperson_id_arr = array_column($data_alesperson['list'],null,'salesperson_id'); + $data_alesperson_id_arr = array_column($data_alesperson['list'],null,'salesperson_id'); $countDataShopList['data_alesperson'] = $data_alesperson; @@ -758,65 +781,81 @@ class BrokerageService public function getSalesmanBrokerageCount($params, $limit, $page){ - $userId = $params['user_id'] ?? 0; - $start = $limit * ($page - 1); + $companyId = $params['company_id'] ?? null; + if (empty($companyId)) { + throw new ResourceException('company_id is required'); + } + $companyId = (int) $companyId; + + $userId = isset($params['user_id']) ? (int) $params['user_id'] : 0; + + $bindParams = ['company_id' => $companyId]; + $types = []; + $sqlWhereParts = [' AND bb.company_id = :company_id ']; - $sqlWhereShopId = ' '; - //is_close if(isset($params['is_close']) ){ $is_close = $params['is_close'] ? 1 : 0; - $sqlWhereIsClose = " and bb.is_close = {$is_close} "; + $bindParams['is_close'] = $is_close; + $sqlWhereParts[] = ' and bb.is_close = :is_close '; } if(isset($params['mobile']) && $params['mobile'] ){ - $sqlWhereIsMobile = " and oo.mobile = '{$params['mobile']}' "; + $bindParams['mobile'] = $params['mobile']; + $sqlWhereParts[] = ' and oo.mobile = :mobile '; } if(isset($params['order_id']) && $params['order_id'] ){ - $sqlWhereIsOrderId = " and bb.order_id = '{$params['order_id']}' "; + $bindParams['order_id'] = $params['order_id']; + $sqlWhereParts[] = ' and bb.order_id = :order_id '; } - if(isset($params['distributor_id']) && !empty($params['distributor_id'])){ - $dIds_str = $params['distributor_id']; - $sqlWhereShopId = " and oo.distributor_id in ( {$dIds_str} ) "; + $dIds = []; + if (isset($params['dIds']) && is_array($params['dIds'])) { + $dIds = array_values(array_filter(array_map('intval', $params['dIds']), function ($v) { + return $v > 0; + })); + } elseif (isset($params['distributor_id']) && !empty($params['distributor_id'])) { + if (is_array($params['distributor_id'])) { + $dIds = array_values(array_filter(array_map('intval', $params['distributor_id']), function ($v) { + return $v > 0; + })); + } else { + $dIds = array_values(array_filter(array_map('intval', explode(',', (string) $params['distributor_id'])), function ($v) { + return $v > 0; + })); + } } - if(isset($params['dIds']) ){//dIds - $dIds_str = implode($params['dIds'], ","); - $sqlWhereShopId = " and oo.distributor_id in ( {$dIds_str} ) "; + if ($dIds) { + $placeholders = []; + foreach ($dIds as $index => $dId) { + $key = 'distributor_id_' . $index; + $placeholders[] = ':' . $key; + $bindParams[$key] = $dId; + } + $sqlWhereParts[] = ' and oo.distributor_id in (' . implode(', ', $placeholders) . ') '; } - if(isset($params['date_start']) ){ - $date_str = strtotime($params['date_start']." 00:00:00"); - $sqlWhereStart = " and bb.created >= ( {$date_str} ) "; + $bindParams['date_start'] = strtotime($params['date_start']." 00:00:00"); + $sqlWhereParts[] = ' and bb.created >= :date_start '; } - + if(isset($params['date_end']) ){ - $date_str = strtotime($params['date_end']." 23:59:59"); - $sqlWhereEnd = " and bb.created <= ( {$date_str} ) "; + $bindParams['date_end'] = strtotime($params['date_end']." 23:59:59"); + $sqlWhereParts[] = ' and bb.created <= :date_end '; } $conn = app("registry")->getConnection('default'); - $qb = $conn->createQueryBuilder(); - // $sql_total = "SELECT count(1) as total"; - // $sql_cls = "SELECT bb.*,oo.distributor_id,oo.title ,oo.total_fee ,ss.name, ss.mobile "; - $sql_sum = "SELECT sum(if(is_close=0,bb.rebate,0)) as rebate_sum_noclose, sum(if(is_close=1,bb.rebate,0)) as rebate_sum_close, sum(bb.rebate) as rebate_sum, sum(price) as price_sum,oo.total_fee ,ss.name, ss.mobile "; $countSql = " FROM popularize_brokerage as bb left join orders_normal_orders oo ON bb.order_id = oo.order_id left join aftersales as aa ON bb.order_id = aa.order_id left join shop_salesperson as ss ON bb.user_id = ss.user_id and oo.distributor_id = ss.shop_id WHERE 1 and bb.source = 'order' and ss.salesperson_id is not null "; - // $countSql .= $sqlWhereDate ; if($userId){ - $countSql .= " AND bb.user_id = {$userId} "; + $bindParams['user_id'] = $userId; + $countSql .= " AND bb.user_id = :user_id "; } - $countSql .= $sqlWhereShopId ?? ' '; - $countSql .= $sqlWhereIsClose ?? ' '; - $countSql .= $sqlWhereIsMobile ?? ' '; - $countSql .= $sqlWhereStart ?? ' '; - $countSql .= $sqlWhereEnd ?? ' '; - $countSql .= $sqlWhereIsOrderId ?? ' '; - // $countSql .= $sqlTab ?? ' '; + $countSql .= implode('', $sqlWhereParts); $countSql .= " order by bb.created desc "; - $countBrolage = $conn->executeQuery( $sql_sum . $countSql)->fetch(); + $countBrolage = $conn->executeQuery($sql_sum . $countSql, $bindParams, $types)->fetch(); app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-sql_sum:". json_encode( $sql_sum . $countSql)); return $countBrolage; @@ -824,123 +863,136 @@ class BrokerageService public function getSalesmanBrokerageCountList($params, $limit = 1000, $page = 1){ + $companyId = $params['company_id'] ?? null; + if (empty($companyId)) { + throw new ResourceException('company_id is required'); + } + $companyId = (int) $companyId; + + $allowedGroupBy = ['distributor_id']; + if (isset($params['groupby']) && !in_array($params['groupby'], $allowedGroupBy, true)) { + throw new ResourceException('Invalid groupby'); + } + $userId = $params['user_id'] ?? 0; - $start = $limit * ($page - 1); + $start = (int) ($limit * ($page - 1)); + $limit = (int) $limit; app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-userId:". json_encode($userId)); - // app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-userId:". var_export($userId)); - $is_list_user = is_array($userId ); - // // app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-$is_list_user:". var_export($is_list_user)); - // if( is_array($userId ) ){ - // $userId = false; - // $userIdArray = $userId; - // app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-userIdArray:". json_encode(($userIdArray ?? [])) ); + $bindParams = ['company_id' => $companyId]; + $types = []; + $sqlWhereParts = [' AND bb.company_id = :company_id ']; - // } - // app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-userIdArray:". json_encode(($userIdArray ?? [])) ); - - $sqlWhereShopId = ' '; - //is_close if(isset($params['is_close']) ){ $is_close = $params['is_close'] ? 1 : 0; - $sqlWhereIsClose = " and bb.is_close = {$is_close} "; + $bindParams['is_close'] = $is_close; + $sqlWhereParts[] = ' and bb.is_close = :is_close '; } if(isset($params['mobile']) && $params['mobile'] ){ - $sqlWhereIsMobile = " and oo.mobile = '{$params['mobile']}' "; + $bindParams['mobile'] = $params['mobile']; + $sqlWhereParts[] = ' and oo.mobile = :mobile '; } if(isset($params['order_id']) && $params['order_id'] ){ - $sqlWhereIsOrderId = " and bb.order_id = '{$params['order_id']}' "; + $bindParams['order_id'] = $params['order_id']; + $sqlWhereParts[] = ' and bb.order_id = :order_id '; } - if(isset($params['distributor_id']) && $params['distributor_id']){ - $dIds_str = $params['distributor_id']; - $sqlWhereShopId = " and oo.distributor_id in ( {$dIds_str} ) "; + $dIds = []; + if (isset($params['dIds']) && is_array($params['dIds'])) { + $dIds = array_values(array_filter(array_map('intval', $params['dIds']), function ($v) { + return $v > 0; + })); + } elseif (isset($params['distributor_id']) && !empty($params['distributor_id'])) { + if (is_array($params['distributor_id'])) { + $dIds = array_values(array_filter(array_map('intval', $params['distributor_id']), function ($v) { + return $v > 0; + })); + } else { + $dIds = array_values(array_filter(array_map('intval', explode(',', (string) $params['distributor_id'])), function ($v) { + return $v > 0; + })); + } } - if(isset($params['dIds']) ){ - $dIds_str = implode($params['dIds'], ","); - $sqlWhereShopId = " and oo.distributor_id in ( {$dIds_str} ) "; + if ($dIds) { + $placeholders = []; + foreach ($dIds as $index => $dId) { + $key = 'distributor_id_' . $index; + $placeholders[] = ':' . $key; + $bindParams[$key] = $dId; + } + $sqlWhereParts[] = ' and oo.distributor_id in (' . implode(', ', $placeholders) . ') '; } - // if($userIdArray){ - // $userIdArray_str = implode($userIdArray, ","); - // $sqlWhereUseridArray = " and bb.user_id in ( {$userIdArray_str} ) "; - // } - if( is_array($userId ) && $userId ){ - $userIdArray_str = implode($userId, ","); - $sqlWhereUseridArray = " and bb.user_id in ( {$userIdArray_str} ) "; + + $userIdArray = []; + if (is_array($userId) && $userId) { + $userIdArray = array_values(array_filter(array_map('intval', $userId), function ($v) { + return $v > 0; + })); $userId = 0; + } elseif (!is_array($userId)) { + $userId = (int) $userId; + } + + if ($userIdArray) { + $placeholders = []; + foreach ($userIdArray as $index => $uid) { + $key = 'user_id_' . $index; + $placeholders[] = ':' . $key; + $bindParams[$key] = $uid; + } + $sqlWhereParts[] = ' and bb.user_id in (' . implode(', ', $placeholders) . ') '; + } - } - // year: 2024 - // month: 2024-07 - // day: 2024-07-30 app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-params:ymd:". json_encode($params)); - $sqlWhereDate = ' '; - if(isset($params['year']) && !empty($params['year'])) { - $sqlWhereDate = " and substr(from_unixtime(bb.created),1,4) = '".$params['year']."' "; - } - if(isset($params['month']) && !empty($params['month']) ) { - $sqlWhereDate = " and substr(from_unixtime(bb.created),1,7) = '".$params['month']."' "; - } if(isset($params['day']) && !empty($params['day'])) { - $sqlWhereDate = " and substr(from_unixtime(bb.created),1,10) = '".$params['day']."' "; + $bindParams['day'] = $params['day']; + $sqlWhereParts[] = ' and substr(from_unixtime(bb.created),1,10) = :day '; + } elseif(isset($params['month']) && !empty($params['month']) ) { + $bindParams['month'] = $params['month']; + $sqlWhereParts[] = ' and substr(from_unixtime(bb.created),1,7) = :month '; + } elseif(isset($params['year']) && !empty($params['year'])) { + $bindParams['year'] = $params['year']; + $sqlWhereParts[] = ' and substr(from_unixtime(bb.created),1,4) = :year '; } if(isset($params['date_start']) ){ - $date_str = strtotime($params['date_start']." 00:00:00"); - $sqlWhereStart = " and bb.created >= ( {$date_str} ) "; - } - - if(isset($params['date_end']) ){ - $date_str = strtotime($params['date_end']." 23:59:59"); - $sqlWhereEnd = " and bb.created <= ( {$date_str} ) "; + $bindParams['date_start'] = strtotime($params['date_start']." 00:00:00"); + $sqlWhereParts[] = ' and bb.created >= :date_start '; } - $sqlGroupBy = " group by bb.user_id,oo.distributor_id "; - // groupby - if(isset($params['groupby']) ){ - $sqlGroupBy .= " , oo.{$params['groupby']} "; + if(isset($params['date_end']) ){ + $bindParams['date_end'] = strtotime($params['date_end']." 23:59:59"); + $sqlWhereParts[] = ' and bb.created <= :date_end '; + } + + $sqlGroupBy = ' group by bb.user_id,oo.distributor_id '; + if(isset($params['groupby']) && $params['groupby'] === 'distributor_id'){ + $sqlGroupBy .= ' , oo.distributor_id '; } $conn = app("registry")->getConnection('default'); - $qb = $conn->createQueryBuilder(); - $sql_total = "SELECT count(1) as total FROM "; - // $sql_cls = "SELECT bb.*,oo.distributor_id,oo.title ,oo.total_fee ,ss.name, ss.mobile "; - $sql_sum = "SELECT ss.salesperson_id,SUM(if(rebate > 0,1,0)) as order_num, SUM(if(rebate < 0,1,0)) as order_num_refund, bb.order_id, bb.user_id, sum(if(is_close=0,bb.rebate,0)) as rebate_sum_noclose,sum(bb.rebate) as rebate_sum, sum(price) as price_sum,oo.total_fee ,ss.name as username, ss.mobile,dd.name as store_name,dd.distributor_id "; $countSql = " FROM popularize_brokerage as bb left join orders_normal_orders oo ON bb.order_id = oo.order_id left join aftersales as aa ON bb.order_id = aa.order_id left join distribution_distributor as dd ON oo.distributor_id = dd.distributor_id left join shop_salesperson as ss ON bb.user_id = ss.user_id and oo.distributor_id = ss.shop_id and ss.user_id > 0 WHERE 1 and oo.distributor_id > 0 AND ss.salesperson_id is not null and bb.source = 'order' and ss.salesperson_id is not null "; - $countSql .= $sqlWhereDate ; if($userId){ - $countSql .= " AND bb.user_id = {$userId} "; + $bindParams['user_id'] = $userId; + $countSql .= " AND bb.user_id = :user_id "; } + $countSql .= implode('', $sqlWhereParts); + $countSql .= $sqlGroupBy; + $countSql .= " order by rebate_sum desc "; - $countSql .= $sqlWhereShopId ?? ' '; - $countSql .= $sqlWhereIsClose ?? ' '; - $countSql .= $sqlWhereIsMobile ?? ' '; - $countSql .= $sqlWhereStart ?? ' '; - $countSql .= $sqlWhereEnd ?? ' '; - $countSql .= $sqlWhereIsOrderId ?? ' '; - $countSql .= $sqlWhereUseridArray ?? ' '; - $countSql .= $sqlGroupBy ?? ' '; - - // $countSql .= $sqlTab ?? ' '; - $countSql .= " order by rebate_sum desc ";//bb.created - - // $countBrolage = $conn->executeQuery( $sql_total ." ( ". $sql_sum . $countSql . " ) as da ")->fetch(); - // app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-sql_total:". $sql_total ." ( ". $countSql . " ) as da " ); - - $countSql .= " limit {$start} , {$limit} "; - $countListBrolage = $conn->executeQuery( $sql_sum . $countSql)->fetchAll(); - // $listBrokerage = $conn->executeQuery($sql_cls . $countSql)->fetchAll(); + $listBindParams = $bindParams; + $listBindParams['offset'] = $start; + $listBindParams['limit'] = $limit; + $countSql .= " limit :offset , :limit "; + $countListBrolage = $conn->executeQuery($sql_sum . $countSql, $listBindParams, $types)->fetchAll(); app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-sql_sum:". json_encode( $sql_sum . $countSql)); - - // $countDataShopList = array('total_count' => intval($countBrolage['total'] ?? 0), 'list'=> $countListBrolage ,'isSalesmanPage' => 1 ); - - - $salesperson_id_arr = array_column($countListBrolage, 'salesperson_id'); + $salesperson_id_arr = array_column($countListBrolage, 'salesperson_id'); + $data_alesperson_id_arr = []; if($salesperson_id_arr){ $filter_salesperson = array( 'salesperson_id' => $salesperson_id_arr, @@ -948,9 +1000,7 @@ class BrokerageService $salespersonService = new SalespersonService(); $data_alesperson = $salespersonService->salesperson->lists($filter_salesperson); - $data_alesperson_id_arr = array_column($data_alesperson['list'],null,'salesperson_id'); - - // $countDataShopList['data_alesperson'] = $data_alesperson; + $data_alesperson_id_arr = array_column($data_alesperson['list'],null,'salesperson_id'); } foreach($countListBrolage as $k => &$v_salesperson){ diff --git a/src/PopularizeBundle/Services/PromoterService.php b/src/PopularizeBundle/Services/PromoterService.php index 2a34a73..21c7ae5 100644 --- a/src/PopularizeBundle/Services/PromoterService.php +++ b/src/PopularizeBundle/Services/PromoterService.php @@ -731,41 +731,49 @@ class PromoterService // FROM popularize_brokerage as bb left join orders_normal_orders oo ON bb.order_id = oo.order_id left join aftersales as aa ON bb.order_id = aa.order_id WHERE bb.user_id = 168 public function getSalesmanCount($authInfo,$params){ - $userId = $authInfo['user_id']; + $companyId = $params['company_id'] ?? $authInfo['company_id'] ?? null; + if (empty($companyId)) { + throw new ResourceException('company_id is required'); + } + $companyId = (int) $companyId; + + $userId = (int) $authInfo['user_id']; if(env('DEBUG_SALESMAN_USERID',false) ){ - $userId = env('DEBUG_SALESMAN_USERID'); + $userId = (int) env('DEBUG_SALESMAN_USERID'); } - $sqlWhereDate = ' '; + + $bindParams = [ + 'company_id' => $companyId, + 'user_id' => $userId, + ]; + $sqlWhereParts = [' AND bb.company_id = :company_id ']; $dateLen = 1; if(isset($params['date']) && $params['date']){ - switch($params['datetype']){ + $bindParams['date_val'] = $params['date']; + switch($params['datetype'] ?? ''){ case 'y': - $date = $params['date']; - $sqlWhereDate = " and substr(from_unixtime(bb.created),1,4) = '".$date."' "; + $sqlWhereParts[] = ' AND substr(from_unixtime(bb.created),1,4) = :date_val '; $dateLen = 7; break; case 'm': - $date = $params['date']; - $sqlWhereDate = " and substr(from_unixtime(bb.created),1,7) = '".$date."' "; + $sqlWhereParts[] = ' AND substr(from_unixtime(bb.created),1,7) = :date_val '; $dateLen = 10; break; case 'd': - $date = $params['date']; - $sqlWhereDate = " and substr(from_unixtime(bb.created),1,10) = '".$date."' "; + $sqlWhereParts[] = ' AND substr(from_unixtime(bb.created),1,10) = :date_val '; $dateLen = 10; break; } } - $sqlWhereShopId = ' '; if(isset($params['distributor_id']) && $params['distributor_id'] ){ - $sqlWhereShopId = " and oo.distributor_id = ".$params['distributor_id'] . ' '; + $bindParams['distributor_id'] = (int) $params['distributor_id']; + $sqlWhereParts[] = ' AND oo.distributor_id = :distributor_id '; } - // $conn = app("registry")->getConnection('default'); - // $qb = $conn->createQueryBuilder(); + $countSql = "SELECT if(count(1)>0 , sum(if(price > 0,1 ,0) ),0) AS order_num, SUM(if(price > 0,total_fee,0)) AS total_Fee, SUM(if(price < 0,total_fee,0)) AS refund_Fee, @@ -777,15 +785,13 @@ class PromoterService FROM popularize_brokerage as bb left join orders_normal_orders oo ON bb.order_id = oo.order_id left join aftersales as aa ON bb.order_id = aa.order_id - WHERE bb.user_id = {$userId} "; - - $countSql .= $sqlWhereDate ; - $countSql .= $sqlWhereShopId ; + WHERE bb.user_id = :user_id "; + $countSql .= implode('', $sqlWhereParts); app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-countSql:". json_encode($countSql)); $conn = app('registry')->getConnection('default'); - $relContents = $conn->executeQuery($countSql)->fetch(); + $relContents = $conn->executeQuery($countSql, $bindParams)->fetch(); return $relContents; @@ -795,12 +801,29 @@ class PromoterService public function getSalesmanStatic($authInfo,$params){ app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-params:". json_encode($params)); - // datetype: y/m/d - // date: 2024/2024-05/2024-05-23 - $sqlWhereDate = ' '; + $companyId = $params['company_id'] ?? $authInfo['company_id'] ?? null; + if (empty($companyId)) { + throw new ResourceException('company_id is required'); + } + $companyId = (int) $companyId; + + $userId = (int) $authInfo['user_id']; + + if(env('DEBUG_SALESMAN_USERID',false) ){ + $userId = (int) env('DEBUG_SALESMAN_USERID'); + + } + + $bindParams = [ + 'company_id' => $companyId, + 'user_id' => $userId, + ]; + $sqlWhereParts = [' AND bb.company_id = :company_id ']; + $sqlWhereDatePlaceholders = ''; $dateLen = 1; $level_config = array('all' => "'first_level', 'second_level' ", 'lv1' => "'first_level'", 'lv2' => "'second_level'"); + $sqlTab = ''; if( isset($params['tab']) && isset($level_config[$params['tab']]) && $level_config[$params['tab']] ) @@ -809,52 +832,45 @@ class PromoterService } $params['datetype'] = $params['datetype'] ?? ''; - switch($params['datetype']){ - case 'y': - $date = $params['date']; - $sqlWhereDate = " and substr(from_unixtime(bb.created),1,4) = '".$date."' "; - $dateLen = 7; - break; - case 'm': - $date = $params['date']; - $sqlWhereDate = " and substr(from_unixtime(bb.created),1,7) = '".$date."' "; - $dateLen = 10; - break; - case 'd': - $date = $params['date']; - $sqlWhereDate = " and substr(from_unixtime(bb.created),1,10) = '".$date."' "; - $dateLen = 10; - break; + if(isset($params['date']) && $params['date']){ + $bindParams['date_val'] = $params['date']; + switch($params['datetype']){ + case 'y': + $sqlWhereDatePlaceholders = ' AND substr(from_unixtime(bb.created),1,4) = :date_val '; + $dateLen = 4; + break; + case 'm': + $sqlWhereDatePlaceholders = ' AND substr(from_unixtime(bb.created),1,7) = :date_val '; + $dateLen = 7; + break; + case 'd': + $sqlWhereDatePlaceholders = ' AND substr(from_unixtime(bb.created),1,10) = :date_val '; + $dateLen = 10; + break; + } + if ($sqlWhereDatePlaceholders) { + $sqlWhereParts[] = $sqlWhereDatePlaceholders; } - - $userId = $authInfo['user_id']; - - if(env('DEBUG_SALESMAN_USERID',false) ){ - $userId = env('DEBUG_SALESMAN_USERID'); - } - $sqlWhereShopId = ' '; if(isset($params['distributor_id']) && $params['distributor_id'] ){ - $sqlWhereShopId = " and oo.distributor_id = ".$params['distributor_id'] . ' '; + $bindParams['distributor_id'] = (int) $params['distributor_id']; + $sqlWhereParts[] = ' AND oo.distributor_id = :distributor_id '; } - $conn = app("registry")->getConnection('default'); - $qb = $conn->createQueryBuilder(); $countSql = "SELECT oo.distributor_id,substr(from_unixtime(bb.created) ,1,{$dateLen}) as date_brokerage , sum(if(price > 0,1 ,0) ) AS order_num, SUM(if(price > 0,total_fee,0)) AS total_Fee, - SUM(if(price < 0,total_fee,0)) AS refund_Fee, if(count(1)>0,sum(bb.rebate),0 ) as total_rebate, if(count(1)>0 ,sum(if(aftersales_bn > 0, 1, 0)),0) as aftersales_num, if(count(1)>0 ,sum(refund_fee),0) as aftersale_Fee, if(count(1)>0,sum(total_fee) /count(1),0) as price_fee, count(distinct oo.user_id) as buy_member_num , concat( oo.user_id) FROM popularize_brokerage as bb left join orders_normal_orders oo ON bb.order_id = oo.order_id left join aftersales as aa ON bb.order_id = aa.order_id WHERE bb.user_id = {$userId} "; - $countSql .= $sqlWhereDate ; - $countSql .= $sqlWhereShopId ; - $countSql .= $sqlTab ?? ' '; - $countSql .= "group by substr(from_unixtime(created) ,1,{$dateLen}) order by created desc"; + SUM(if(price < 0,total_fee,0)) AS refund_Fee, if(count(1)>0,sum(bb.rebate),0 ) as total_rebate, if(count(1)>0 ,sum(if(aftersales_bn > 0, 1, 0)),0) as aftersales_num, if(count(1)>0 ,sum(refund_fee),0) as aftersale_Fee, if(count(1)>0,sum(total_fee) /count(1),0) as price_fee, count(distinct oo.user_id) as buy_member_num , concat( oo.user_id) FROM popularize_brokerage as bb left join orders_normal_orders oo ON bb.order_id = oo.order_id left join aftersales as aa ON bb.order_id = aa.order_id WHERE bb.user_id = :user_id "; + $countSql .= implode('', $sqlWhereParts); + $countSql .= $sqlTab; + $countSql .= "group by substr(from_unixtime(bb.created) ,1,{$dateLen}) order by bb.created desc"; $conn = app('registry')->getConnection('default'); app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-countSql:". json_encode($countSql)); - $listBrokerage = $conn->executeQuery($countSql)->fetchAll(); + $listBrokerage = $conn->executeQuery($countSql, $bindParams)->fetchAll(); app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-listBrokerage:". json_encode($listBrokerage)); // 统计推广员增加人数 - $listPromoter = $this->getSalesPromotersStatic($userId, $dateLen , $sqlWhereDate, $sqlWhereShopId ) ; + $listPromoter = $this->getSalesPromotersStatic($userId, $companyId, $dateLen, $sqlWhereDatePlaceholders, $bindParams) ; app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-listPromoter:". json_encode($listPromoter)); // 合并数据 @@ -865,16 +881,14 @@ class PromoterService } - public function getSalesPromotersStatic($userId, $dateLen , $sqlWhereDate, $sqlWhereShopId ) { + public function getSalesPromotersStatic($userId, $companyId, $dateLen, $sqlWhereDatePlaceholders, array $bindParams) { $conn = app("registry")->getConnection('default'); - $qb = $conn->createQueryBuilder(); - $countSql = "select substr(from_unixtime(created) ,1,{$dateLen}) as date_brokerage , count(1) as member_num from popularize_promoter bb WHERE bb.pid = {$userId} "; - $countSql .= $sqlWhereDate ; - $countSql .= "group by substr(from_unixtime(created) ,1,{$dateLen}) order by created desc"; - $conn = app('registry')->getConnection('default'); + $countSql = "select substr(from_unixtime(bb.created) ,1,{$dateLen}) as date_brokerage , count(1) as member_num from popularize_promoter bb WHERE bb.pid = :user_id AND bb.company_id = :company_id "; + $countSql .= $sqlWhereDatePlaceholders; + $countSql .= "group by substr(from_unixtime(bb.created) ,1,{$dateLen}) order by bb.created desc"; app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-countSql:". json_encode($countSql)); - $listPromoter = $conn->executeQuery($countSql)->fetchAll(); + $listPromoter = $conn->executeQuery($countSql, $bindParams)->fetchAll(); app('log')->debug("\n".__FUNCTION__."-".__LINE__.":in-listPromoter:". json_encode($listPromoter)); return is_array($listPromoter) ? $listPromoter : array(); diff --git a/src/SalespersonBundle/Http/FrontApi/V1/Action/ShopSalespersonController.php b/src/SalespersonBundle/Http/FrontApi/V1/Action/ShopSalespersonController.php index 62badeb..6a0212c 100644 --- a/src/SalespersonBundle/Http/FrontApi/V1/Action/ShopSalespersonController.php +++ b/src/SalespersonBundle/Http/FrontApi/V1/Action/ShopSalespersonController.php @@ -587,8 +587,27 @@ class ShopSalespersonController extends Controller public function brokagestaticlist(Request $request){ $authInfo = $request->get('auth'); $inputData = $request->all(); - // $filter = array(); $inputData['company_id'] = $authInfo['company_id']; + + if (isset($inputData['groupby']) && $inputData['groupby'] !== 'distributor_id') { + throw new ResourceException('Invalid groupby'); + } + if (isset($inputData['groupby'])) { + $inputData['groupby'] = 'distributor_id'; + } + + $validateParams = $request->all('distributor_id', 'page', 'pageSize'); + $validateRules = [ + 'distributor_id' => ['nullable|integer', 'distributor_id参数错误'], + 'page' => ['nullable|integer|min:1', '分页参数错误'], + 'pageSize' => ['nullable|integer|min:1', '每页数量参数错误'], + ]; + $error = validator_params($validateParams, $validateRules); + if ($error) { + throw new ResourceException($error); + } + + // $filter = array(); // $filter['distributor_id'] = $inputData['distributor_id']; // diff --git a/src/SupplierBundle/Entities/SupplierItemsDraft.php b/src/SupplierBundle/Entities/SupplierItemsDraft.php new file mode 100644 index 0000000..6bac175 --- /dev/null +++ b/src/SupplierBundle/Entities/SupplierItemsDraft.php @@ -0,0 +1,212 @@ +draft_id; + } + + public function setSourceItemId($sourceItemId) + { + $this->source_item_id = $sourceItemId; + return $this; + } + + public function getSourceItemId() + { + return $this->source_item_id; + } + + public function setGoodsId($goodsId) + { + $this->goods_id = $goodsId; + return $this; + } + + public function getGoodsId() + { + return $this->goods_id; + } + + public function setCompanyId($companyId) + { + $this->company_id = $companyId; + return $this; + } + + public function getCompanyId() + { + return $this->company_id; + } + + public function setSupplierId($supplierId) + { + $this->supplier_id = $supplierId; + return $this; + } + + public function getSupplierId() + { + return $this->supplier_id; + } + + public function setDefaultItemId($defaultItemId = null) + { + $this->default_item_id = $defaultItemId; + return $this; + } + + public function getDefaultItemId() + { + return $this->default_item_id; + } + + public function setIsDefault($isDefault) + { + $this->is_default = $isDefault; + return $this; + } + + public function getIsDefault() + { + return $this->is_default; + } + + public function setContentJson($contentJson) + { + $this->content_json = $contentJson; + return $this; + } + + public function getContentJson() + { + return $this->content_json; + } + + public function setCreated($created) + { + $this->created = $created; + return $this; + } + + public function getCreated() + { + return $this->created; + } + + public function setUpdated($updated = null) + { + $this->updated = $updated; + return $this; + } + + public function getUpdated() + { + return $this->updated; + } +} diff --git a/src/SupplierBundle/Repositories/SupplierItemsAttrDraftRepository.php b/src/SupplierBundle/Repositories/SupplierItemsAttrDraftRepository.php index d572909..2448343 100644 --- a/src/SupplierBundle/Repositories/SupplierItemsAttrDraftRepository.php +++ b/src/SupplierBundle/Repositories/SupplierItemsAttrDraftRepository.php @@ -19,6 +19,12 @@ namespace SupplierBundle\Repositories; use SupplierBundle\Entities\SupplierItemsAttrDraft; +/** + * supplier_items_attr_draft 表 Repository。 + * + * 与 supplier_items_attr 结构对称,staging 期间分类/品牌/规格/参数写此表, + * mergeDraftToMain 时再同步到主表 attr。 + */ class SupplierItemsAttrDraftRepository extends BaseRepository { public $table = 'supplier_items_attr_draft'; diff --git a/src/SupplierBundle/Repositories/SupplierItemsDraftRepository.php b/src/SupplierBundle/Repositories/SupplierItemsDraftRepository.php index 5b7ec66..935dae0 100644 --- a/src/SupplierBundle/Repositories/SupplierItemsDraftRepository.php +++ b/src/SupplierBundle/Repositories/SupplierItemsDraftRepository.php @@ -17,6 +17,14 @@ namespace SupplierBundle\Repositories; +/** + * supplier_items_draft 表访问层。 + * + * 表结构要点: + * - source_item_id:对应主表 supplier_items.item_id + * - goods_id:SPU 维度,便于按整组商品查询/删除 + * - content_json:SKU 待审内容(decodeRow 时 merge 到行内供业务层使用) + */ class SupplierItemsDraftRepository { public $table = 'supplier_items_draft'; @@ -66,6 +74,7 @@ class SupplierItemsDraftRepository return (int)$qb->execute()->fetchColumn() > 0; } + /** 新建 draft 行,content 数组自动序列化为 content_json。 */ public function create(array $data) { $now = time(); @@ -102,6 +111,7 @@ class SupplierItemsDraftRepository return $this->getInfo(['draft_id' => $row['draft_id']]); } + /** 审核驳回或 merge 完成后,按 goods_id 删除整 SPU 的 SKU draft。 */ public function deleteByGoodsId($goodsId, $companyId = null) { $conn = $this->connection(); @@ -115,6 +125,7 @@ class SupplierItemsDraftRepository return $qb->execute(); } + /** 将 content_json 解码并 merge 到行数组,供 overlay/merge 使用。 */ public function decodeRow(array $row) { if (!empty($row['content_json'])) { diff --git a/src/SupplierBundle/Services/SupplierItemsDraftService.php b/src/SupplierBundle/Services/SupplierItemsDraftService.php index 4df1a97..ee30c7c 100644 --- a/src/SupplierBundle/Services/SupplierItemsDraftService.php +++ b/src/SupplierBundle/Services/SupplierItemsDraftService.php @@ -22,12 +22,25 @@ use SupplierBundle\Entities\SupplierItemsAttrDraft; use SupplierBundle\Repositories\SupplierItemsDraftRepository; use SupplierBundle\Support\SupplierItemsDraftFields; +/** + * 供应商商品草稿(staging)领域服务。 + * + * 数据模型: + * - supplier_items_draft:按 goods_id + source_item_id 存 SKU 级 content_json + * - supplier_items_attr_draft:分类/品牌/规格/参数等待审属性 + * + * 生命周期: + * 1. saveDraftSku / saveAttrDraft —— 供应商编辑保存(processing 期间可覆盖) + * 2. overlayDraftOnMainRows —— 详情读路径展示待审内容 + * 3. mergeDraftToMain —— 平台 approved 后合并到主表并删 draft + * 4. deleteDraftByGoodsId —— 平台 rejected 后丢弃 draft + */ class SupplierItemsDraftService { - /** @var SupplierItemsDraftRepository */ + /** @var SupplierItemsDraftRepository SKU 草稿表访问 */ public $draftRepository; - /** @var \SupplierBundle\Repositories\SupplierItemsAttrDraftRepository */ + /** @var \SupplierBundle\Repositories\SupplierItemsAttrDraftRepository 属性草稿表访问 */ public $attrDraftRepository; public function __construct() @@ -36,11 +49,19 @@ class SupplierItemsDraftService $this->attrDraftRepository = app('registry')->getManager('default')->getRepository(SupplierItemsAttrDraft::class); } + /** + * 指定 SPU 是否存在待审 draft 行(任意 SKU 有记录即 true)。 + */ public function hasPendingDraft($goodsId, $companyId = null) { return $this->draftRepository->existsByGoodsId($goodsId, $companyId); } + /** + * 供应商 SKU 是否已在平台商品池 items 中建立 supplier_item_id 映射。 + * + * 有映射表示商品曾同步到 C 端,即使 audit_status 非 approved 也应 staging 保护。 + */ public function hasPlatformMapping(array $sourceItemIds) { if (!$sourceItemIds) { @@ -51,6 +72,11 @@ class SupplierItemsDraftService return !empty($rows); } + /** + * 按 SPU 下全部 SKU 主表行判定是否走 staging 写入。 + * + * 取首行 audit_status + 全 SKU 的 platform 映射,委托 DraftFields::shouldUseStaging。 + */ public function shouldUseStagingForGoods(array $mainSkuRows) { if (!$mainSkuRows) { @@ -62,6 +88,9 @@ class SupplierItemsDraftService return SupplierItemsDraftFields::shouldUseStaging($auditStatus, $this->hasPlatformMapping($itemIds)); } + /** + * 获取某 SPU 下全部 SKU draft,content_json 已 decode 合并到行内。 + */ public function getDraftSkusByGoodsId($goodsId, $companyId = null) { $filter = ['goods_id' => $goodsId]; @@ -72,6 +101,12 @@ class SupplierItemsDraftService return $this->draftRepository->decodeRows($rows); } + /** + * 保存或更新单个 SKU 的 draft 行(upsert by source_item_id + goods_id)。 + * + * @param array $meta draft 表列:source_item_id, goods_id, company_id 等 + * @param array $content 待审内容字段,会经 splitRow 过滤后写入 content_json + */ public function saveDraftSku(array $meta, array $content) { $split = SupplierItemsDraftFields::splitRow(array_merge($meta, $content)); @@ -96,6 +131,9 @@ class SupplierItemsDraftService return $this->draftRepository->create($payload); } + /** + * 删除某 SPU 全部 draft(SKU + attr),用于驳回或 merge 完成后清理。 + */ public function deleteDraftByGoodsId($goodsId, $companyId = null) { $this->draftRepository->deleteByGoodsId($goodsId, $companyId); @@ -106,6 +144,11 @@ class SupplierItemsDraftService $this->attrDraftRepository->deleteBy($filter); } + /** + * 保存属性 draft(category / brand / item_spec / item_params)。 + * + * 与 SupplierItemsAttrService::saveAttrData 对称,staging 期间写 attr_draft 表。 + */ public function saveAttrDraft(array $filter, $attrData, $goodsId) { if (is_array($attrData)) { @@ -121,6 +164,9 @@ class SupplierItemsDraftService return $this->attrDraftRepository->create($filter); } + /** + * 标记属性 draft 待删除(save 前先软删旧关联,与主表 attr 的 setDelData 模式一致)。 + */ public function setAttrDelData(array $filter, $goodsId) { $filter['goods_id'] = $goodsId; @@ -129,6 +175,9 @@ class SupplierItemsDraftService } } + /** + * 物理删除已标记 is_del=1 的属性 draft 行。 + */ public function execAttrDelData(array $filter, $goodsId) { $filter['goods_id'] = $goodsId; @@ -136,6 +185,16 @@ class SupplierItemsDraftService $this->attrDraftRepository->deleteBy($filter); } + /** + * 平台审核通过:draft SKU + attr 合并到主表,然后删除全部 draft。 + * + * 关键节点:按 source_item_id 逐 SKU merge,仅覆盖 content 字段,不动 store/audit 等 MAIN_ONLY。 + * + * @param int|string $goodsId SPU goods_id + * @param object $supplierItemsRepository SupplierItems Repository + * @param int|string|null $companyId + * @return bool false 表示无 draft 可 merge + */ public function mergeDraftToMain($goodsId, $supplierItemsRepository, $companyId = null) { $draftSkus = $this->getDraftSkusByGoodsId($goodsId, $companyId); @@ -161,6 +220,9 @@ class SupplierItemsDraftService return true; } + /** + * 将 attr_draft 合并到 supplier_items_attr 主表。 + */ private function mergeAttrDraftToMain($goodsId, $companyId = null) { $filter = ['goods_id' => $goodsId]; @@ -189,6 +251,9 @@ class SupplierItemsDraftService } } + /** + * 详情读路径:用 draft 覆盖主表行列表中的内容字段(内存 overlay,不写库)。 + */ public function overlayDraftOnMainRows(array $mainRows, $goodsId, $companyId = null) { $draftSkus = $this->getDraftSkusByGoodsId($goodsId, $companyId); @@ -203,6 +268,9 @@ class SupplierItemsDraftService return SupplierItemsDraftFields::overlayDraftRows($mainRows, $draftBySource); } + /** + * 从 attr_draft 读取单个属性类型的数据(如 category),供 getItemsDetail 使用。 + */ public function getAttrData($itemId, $attributeType, $goodsId, $companyId = null) { $filter = [ @@ -221,6 +289,9 @@ class SupplierItemsDraftService return []; } + /** + * 从 attr_draft 批量读取属性列表(规格图、item_spec 等),结构与主表 attr 查询对齐。 + */ public function getAttrDataList($itemIds, $goodsId, $attributeTypes = [], $companyId = null) { if (!is_array($itemIds)) { diff --git a/src/SupplierBundle/Services/SupplierItemsService.php b/src/SupplierBundle/Services/SupplierItemsService.php index bc05c3a..1c45370 100644 --- a/src/SupplierBundle/Services/SupplierItemsService.php +++ b/src/SupplierBundle/Services/SupplierItemsService.php @@ -46,13 +46,13 @@ class SupplierItemsService public $repository; public $supplierItemsAttrRepository; - /** @var bool */ + /** @var bool 当前 addItems/batch 请求是否走 staging(内容写 draft,主表仅更新 audit 等) */ private $stagingActive = false; - /** @var int|null */ + /** @var int|null 当前 staging 上下文对应的 SPU goods_id */ private $stagingGoodsId = null; - /** @var SupplierItemsDraftService|null */ + /** @var SupplierItemsDraftService|null staging 草稿服务(懒加载) */ private $draftService; public function __construct() @@ -61,6 +61,7 @@ class SupplierItemsService $this->supplierItemsAttrRepository = app('registry')->getManager('default')->getRepository(SupplierItemsAttr::class); } + /** 懒加载 SupplierItemsDraftService,避免非 staging 请求无谓实例化。 */ private function getDraftService() { if (!$this->draftService) { @@ -69,6 +70,12 @@ class SupplierItemsService return $this->draftService; } + /** + * 按 goods_id 判定并设置本次请求的 staging 上下文。 + * + * 在 addItems 解析出 goods_id 后、createItems 循环前调用。 + * 若 SPU 曾 approved 或已有平台映射,则 stagingActive=true,后续 SKU 更新走 saveStagedItemUpdate。 + */ private function resolveStagingForGoods($goodsId) { $this->stagingActive = false; @@ -330,6 +337,7 @@ class SupplierItemsService $goodsId = $updateItemInfo['goods_id']; } + // staging 入口:确定 goods_id 后判定本请求是否写 draft(见 resolveStagingForGoods) if ($goodsId) { $this->resolveStagingForGoods($goodsId); } else { @@ -475,6 +483,9 @@ class SupplierItemsService return $itemsResult; } + /** + * 关联销售分类。staging 时写 supplier_items_attr_draft,merge/驳回前不污染主表 attr。 + */ private function itemsRelCats($params, $defaultItemId) { if (isset($params['company_id']) && isset($params['item_category']) && $params['item_category'] && $defaultItemId) { @@ -498,7 +509,7 @@ class SupplierItemsService } /** - * 商品关联品牌 如果为单规格关联当前商品ID,多规格关联默认商品ID + * 商品关联品牌。staging 时写 attr_draft。 */ private function itemsRelBrand($params, $defaultItemId) { @@ -522,7 +533,7 @@ class SupplierItemsService } /** - * 商品关联参数 如果为单规格关联当前商品ID,多规格关联默认商品ID + * 商品关联参数。staging 时走 attr_draft 的软删+重建模式(与主表 attr 一致)。 */ private function itemsRelParams($params, $defaultItemId) { @@ -578,9 +589,11 @@ class SupplierItemsService $data['audit_reason'] = ''; $data['audit_date'] = ''; } + // staging 分支:已审核/已上线商品,内容进 draft,主表仅更新 audit_status 等 MAIN_ONLY 字段 if ($this->stagingActive) { $itemsResult = $this->saveStagedItemUpdate($data, $spec_params); } else { + // 从未 approved 且无平台映射:直写 supplier_items 主表 $itemsResult = $this->repository->updateOneBy(['item_id' => $spec_params['item_id']], $data); } } else { @@ -595,6 +608,7 @@ class SupplierItemsService $this->stagingActive, $this->getDraftService()->hasPendingDraft($itemsResult['goods_id'] ?? 0, $data['company_id'] ?? null) ); + // 待审期间禁止向平台 items 同步名称/图片/销售状态等内容字段 if ($rsItem && !$blockContentSync) { $upData = [ 'store' => $data['store'], @@ -680,6 +694,12 @@ class SupplierItemsService return $itemsResult; } + /** + * staging 模式下更新单个 SKU:主表写 MAIN_ONLY,内容写 supplier_items_draft。 + * + * 由 createItems(编辑)或 batchUpdateItems(批量开售/停售)调用。 + * 主表 approve_status/is_market 在 merge 前保持不变,保护已通过版本。 + */ private function saveStagedItemUpdate(array $data, array $spec_params) { $sourceItemId = $spec_params['item_id']; @@ -1143,6 +1163,7 @@ class SupplierItemsService $goodsId = $itemsInfo['goods_id']; $hasDraft = $draftService->hasPendingDraft($goodsId, $itemsInfo['company_id']); $operatorType = app('auth')->user()->get('operator_type') ?? 'supplier'; + // 关键读分流:供应商待审读 draft;驳回读主表;平台仅 processing 读 draft $readDraft = SupplierItemsDetailStaging::resolveReadDraft( $itemsInfo['audit_status'] ?? '', $hasDraft, @@ -1154,6 +1175,8 @@ class SupplierItemsService } $itemsInfo['data_source'] = 'supplier_goods'; + // 非待审读 draft 时清空 approve_status:供应商端 UI 以 SPU 级 is_market 为准 + // readDraft=true 时保留 overlay 后的 approve_status(含待审销售状态变更) if (!$readDraft) { $itemsInfo['approve_status'] = ''; } @@ -1398,11 +1421,13 @@ class SupplierItemsService $this->repository->updateBy(['goods_id' => $supplierGoods['goods_id']], $saveData); $draftService = $this->getDraftService(); + // 驳回:删 draft,主表内容保持编辑前已通过版本 if (SupplierItemsReviewStaging::shouldDeleteDraftOnReject($params['audit_status'])) { $draftService->deleteDraftByGoodsId($supplierGoods['goods_id'], $companyId); return $itemId; } + // 通过:draft merge 到主表后再 sync 平台池(下方 approved 分支读的是 merge 后的主表) if (SupplierItemsReviewStaging::shouldMergeDraftOnApprove($params['audit_status'])) { $draftService->mergeDraftToMain($supplierGoods['goods_id'], $this->repository, $companyId); } @@ -1426,6 +1451,7 @@ class SupplierItemsService } foreach ($supplierGoodsList as $v) { $v['supplier_item_id'] = $v['item_id']; + // merge 后主表已有 draft 中的 approve_status;仅缺失时按 is_market 推导 if (empty($v['approve_status'])) { $v['approve_status'] = $v['is_market'] ? 'onsale' : 'instock'; } @@ -1643,8 +1669,13 @@ class SupplierItemsService } /** - * 供应商端目前只支持 - * 1.批量提交审核,2.批量设置停售和开售 + * 供应商端批量操作:提交审核 / 批量开售停售。 + * + * 开售停售(is_market)分流规则: + * - 已审核/已上线 SPU:is_market + approve_status 写 draft,主表 audit_status=processing + * - 从未 approved:直写主表(与历史行为一致) + * 库存 updateItemsStore 不在此方法,始终直写主表。 + * * @param array $filter 更新条件 * @param array $params 更新数据 * @return @@ -1677,6 +1708,7 @@ class SupplierItemsService $draftService = $this->getDraftService(); foreach ($byGoods as $goodsId => $skuRows) { if ($draftService->shouldUseStagingForGoods($skuRows)) { + // 已上线商品:每个 SKU 走 saveStagedItemUpdate,销售状态进 draft $this->resolveStagingForGoods($goodsId); foreach ($skuRows as $row) { $stagedData = [ diff --git a/src/SupplierBundle/Support/SupplierItemsDetailStaging.php b/src/SupplierBundle/Support/SupplierItemsDetailStaging.php index a8ea813..faf868a 100644 --- a/src/SupplierBundle/Support/SupplierItemsDetailStaging.php +++ b/src/SupplierBundle/Support/SupplierItemsDetailStaging.php @@ -16,8 +16,22 @@ namespace SupplierBundle\Support; +/** + * 商品详情读路径的 draft 判定入口。 + * + * 封装 operator_type → 平台/供应商 的差异,供 getItemsDetail 统一调用。 + * 列表 getItemsList 不经过此类,始终读主表生效数据。 + */ class SupplierItemsDetailStaging { + /** + * 解析当前详情请求是否应 overlay 草稿内容。 + * + * @param string $auditStatus 主表 audit_status + * @param bool $hasDraft supplier_items_draft 是否存在该 goods_id 记录 + * @param string $operatorType 登录方:supplier | platform 等 + * @return bool true 时 getItemsDetail 用 overlayDraftOnMainRows 替换内容字段 + */ public static function resolveReadDraft($auditStatus, $hasDraft, $operatorType = 'supplier') { $isPlatformReview = $operatorType !== 'supplier'; diff --git a/src/SupplierBundle/Support/SupplierItemsDraftFields.php b/src/SupplierBundle/Support/SupplierItemsDraftFields.php index ac33148..f9293f8 100644 --- a/src/SupplierBundle/Support/SupplierItemsDraftFields.php +++ b/src/SupplierBundle/Support/SupplierItemsDraftFields.php @@ -17,9 +17,28 @@ namespace SupplierBundle\Support; +/** + * 供应商商品「主表 / 草稿」字段分层与读写判定。 + * + * 业务背景:已审核通过的商品再次编辑时,内容字段写入 supplier_items_draft, + * 主表 supplier_items 保持已通过版本;平台审核通过后再 merge 回主表。 + * + * 字段分层规则(见常量): + * - MAIN_ONLY:始终写/读主表(审核状态、库存、销量等) + * - DRAFT_META:草稿行关联元数据,存 draft 表列,不进 content_json + * - 其余字段:内容字段,staging 时进 draft.content_json(含 is_market、approve_status) + */ class SupplierItemsDraftFields { - /** @var string[] 主表独占:审核、实时、结构字段 */ + /** + * 主表独占字段:staging 时不写入 draft,merge 时也不被 draft 覆盖。 + * + * - audit_*:审核流程状态,仅主表维护 + * - store/sales:库存/销量实时生效,不走草稿 + * - created/updated:主表时间戳 + * + * 注意:item_id 在 split 时归入 main,仅用于区分写入目标,不会进入 content_json。 + */ public const MAIN_ONLY_FIELDS = [ 'item_id', 'audit_status', @@ -31,7 +50,11 @@ class SupplierItemsDraftFields 'updated', ]; - /** @var string[] 草稿行元数据字段(不进 content_json) */ + /** + * 草稿表行级元数据:对应 supplier_items_draft 的独立列。 + * + * splitRow 时这些键既不进 content 也不进 main(由 saveDraftSku 单独持久化)。 + */ public const DRAFT_META_FIELDS = [ 'draft_id', 'source_item_id', @@ -42,11 +65,21 @@ class SupplierItemsDraftFields 'is_default', ]; - /** @var string[] 待审 status:编辑页读 draft */ + /** + * 待审中的 audit_status 集合。 + * + * 此状态下供应商编辑页、平台审核页(部分场景)应 overlay 草稿内容。 + * submiting 为历史拼写,与 submitting 并存以兼容旧数据。 + */ public const PENDING_AUDIT_STATUSES = ['submitting', 'submiting', 'processing']; /** - * @param array $row + * 将一行商品数据拆分为「草稿内容」与「主表字段」。 + * + * 写入 staging 时的第一道拆分:incoming 里 audit_status/store 等进 main, + * item_name/price/approve_status 等进 content。 + * + * @param array $row 合并后的单行数据(可能来自请求或 DB decode) * @return array{content: array, main: array} */ public static function splitRow(array $row): array @@ -65,9 +98,14 @@ class SupplierItemsDraftFields } /** - * @param array $mainRow - * @param array $content - * @return array + * 将草稿 content 覆盖合并到主表行(内存层,不写库)。 + * + * mergeDraftToMain、overlayDraftRows、getItemsDetail 读 draft 时均依赖此方法。 + * MAIN_ONLY 与 DRAFT_META 字段不会被 content 覆盖,保证主表审核/库存语义不变。 + * + * @param array $mainRow 主表当前行 + * @param array $content 草稿 content_json 解码后的字段 + * @return array 合并后的展示/更新用行 */ public static function mergeContentIntoRow(array $mainRow, array $content): array { @@ -80,11 +118,26 @@ class SupplierItemsDraftFields return $mainRow; } + /** + * 判定单个 SKU/SPU 是否应走 staging 写入。 + * + * 满足任一即 staging: + * 1. 主表 audit_status === approved(曾有过已通过版本,需保护主表内容) + * 2. 平台商品池 items 已存在 supplier_item_id 映射(已同步过平台,视为已上线) + * + * 从未 approved 且无平台映射的新建/首次提审商品返回 false,直写主表。 + */ public static function shouldUseStaging(string $auditStatus, bool $hasPlatformMapping): bool { return $auditStatus === 'approved' || $hasPlatformMapping; } + /** + * 判定是否应从 draft 读取内容(供应商侧默认规则)。 + * + * 条件:存在 draft 且 audit_status 处于待审集合。 + * rejected 返回 false —— 驳回后编辑页回显主表已通过版本,不读 draft。 + */ public static function shouldReadDraft(?string $auditStatus, bool $hasDraft): bool { if (!$hasDraft || $auditStatus === null) { @@ -94,6 +147,14 @@ class SupplierItemsDraftFields return in_array($auditStatus, self::PENDING_AUDIT_STATUSES, true); } + /** + * 详情接口读 draft 的细化规则(区分供应商 vs 平台审核方)。 + * + * - 供应商:submitting/submiting/processing 且有 draft 时读 draft + * - 平台:仅 processing 且有 draft 时读 draft(submitting 阶段平台不可见) + * + * @param bool $isPlatformReview true 表示 operator_type !== supplier + */ public static function shouldReadDraftForDetail(?string $auditStatus, bool $hasDraft, bool $isPlatformReview = false): bool { if (!$hasDraft) { @@ -108,8 +169,13 @@ class SupplierItemsDraftFields } /** - * @param array> $mainRows - * @param array> $draftBySourceId keyed by source_item_id + * 批量将 draft 内容 overlay 到主表行列表(按 source_item_id 对齐)。 + * + * 多规格场景:每个 SKU 的 item_id 对应 draft.source_item_id。 + * 无匹配 draft 的 SKU 保持主表原样。 + * + * @param array> $mainRows 主表 SKU 列表 + * @param array> $draftBySourceId key = source_item_id * @return array> */ public static function overlayDraftRows(array $mainRows, array $draftBySourceId): array diff --git a/src/SupplierBundle/Support/SupplierItemsPlatformReview.php b/src/SupplierBundle/Support/SupplierItemsPlatformReview.php index 2b5be93..36e8f42 100644 --- a/src/SupplierBundle/Support/SupplierItemsPlatformReview.php +++ b/src/SupplierBundle/Support/SupplierItemsPlatformReview.php @@ -11,13 +11,25 @@ * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and limitations under the License. + * See the License for the specific language governing permissions and + * limitations under the License. */ namespace SupplierBundle\Support; +/** + * 平台审核通过时的写入策略开关。 + * + * 历史行为:平台 approved 时曾直接调用 addItems 全量写主表。 + * 现改为 reviewGoods 内 merge draft 后再 sync 平台池,此处固定返回 false 禁止直写。 + */ class SupplierItemsPlatformReview { + /** + * 平台审核通过时是否绕过 staging 直写主表。 + * + * @return bool 恒为 false,表示必须走 mergeDraftToMain 流程 + */ public static function shouldDirectWriteMainOnApprove() { return false; diff --git a/src/SupplierBundle/Support/SupplierItemsReviewStaging.php b/src/SupplierBundle/Support/SupplierItemsReviewStaging.php index 8264c84..86637a8 100644 --- a/src/SupplierBundle/Support/SupplierItemsReviewStaging.php +++ b/src/SupplierBundle/Support/SupplierItemsReviewStaging.php @@ -17,13 +17,26 @@ namespace SupplierBundle\Support; +/** + * 平台审核 reviewGoods 时的 draft 分支判定。 + * + * 与 SupplierItemsDraftService::mergeDraftToMain / deleteDraftByGoodsId 配合: + * - approved:merge draft → 主表,再同步平台池 + * - rejected:删 draft,主表内容不变(保留已通过版本) + */ class SupplierItemsReviewStaging { + /** + * 审核通过时是否执行 draft → 主表 merge。 + */ public static function shouldMergeDraftOnApprove($auditStatus) { return $auditStatus === 'approved'; } + /** + * 审核驳回时是否丢弃 draft(不 merge,主表内容保持编辑前状态)。 + */ public static function shouldDeleteDraftOnReject($auditStatus) { return $auditStatus === 'rejected'; diff --git a/src/SupplierBundle/Support/SupplierItemsStagingWriter.php b/src/SupplierBundle/Support/SupplierItemsStagingWriter.php index 61f0f2f..8c36128 100644 --- a/src/SupplierBundle/Support/SupplierItemsStagingWriter.php +++ b/src/SupplierBundle/Support/SupplierItemsStagingWriter.php @@ -17,10 +17,36 @@ namespace SupplierBundle\Support; +/** + * 供应商商品 staging 写入编排器。 + * + * 在 SupplierItemsService::saveStagedItemUpdate 中调用,负责把一次保存请求 + * 拆成「主表更新 payload」「draft 元数据」「draft 内容」三部分,供 DraftService 落库。 + * + * 典型调用链: + * addItems → resolveStagingForGoods → createItems(stagingActive) → saveStagedItemUpdate + * → prepareStagedUpdate → repository.updateOneBy(main) + saveDraftSku + */ class SupplierItemsStagingWriter { /** - * @return array{main: array, draft_meta: array, draft_content: array} + * 准备 staging 写入的三段数据。 + * + * @param array $incomingData 经 itemSpecParams 处理后的 SKU 级写入数据 + * @param array $mainRow 主表当前行(用于补全 goods_id/supplier_id 等) + * @param array $specParams 规格参数,必须含 item_id(source_item_id) + * @param int|string|null $goodsId SPU 级 goods_id,为空时回退 mainRow/specParams + * + * @return array{ + * main: array, + * draft_meta: array, + * draft_content: array + * } + * + * 返回值说明: + * - main:仅含 MAIN_ONLY 字段(如 audit_status),直接 update supplier_items + * - draft_meta:draft 表列字段,标识 source_item_id / goods_id 等关联 + * - draft_content:进 content_json 的待审变更(名称、价格、销售状态等) */ public static function prepareStagedUpdate(array $incomingData, array $mainRow, array $specParams, $goodsId) { @@ -41,6 +67,17 @@ class SupplierItemsStagingWriter ]; } + /** + * 是否应阻止 createItems 内向平台 items 表的部分内容同步。 + * + * 待审期间(stagingActive 或已有 pending draft)不允许把名称/图片/销售状态等 + * 泄漏写入平台商品池,避免 C 端看到未审核内容。 + * + * 库存 store 的同步逻辑在 createItems 内单独处理;updateItemsStore 始终直写主表+平台。 + * + * @param bool $stagingActive 当前请求是否已判定走 staging + * @param bool $hasPendingDraft 该 goods_id 是否已有 draft 行(含 processing 中二次编辑) + */ public static function shouldBlockPlatformContentSync($stagingActive, $hasPendingDraft) { return $stagingActive || $hasPendingDraft; diff --git a/src/ThirdPartyBundle/Http/ThirdApi/V1/Action/Delivery.php b/src/ThirdPartyBundle/Http/ThirdApi/V1/Action/Delivery.php index 98290a0..68eb318 100644 --- a/src/ThirdPartyBundle/Http/ThirdApi/V1/Action/Delivery.php +++ b/src/ThirdPartyBundle/Http/ThirdApi/V1/Action/Delivery.php @@ -119,8 +119,8 @@ class Delivery extends Controller } $delivery_num[$row['product_bn']] += $row['number']; } - $ship_mobile = $data['ship_mobile']; - $logi_name = $data['logi_name']; + $ship_mobile = $data['ship_mobile'] ?? null; + $logi_name = $data['logi_name'] ?? null; $delivery_code = $data['logi_no']; $delivery_corp = $data['logi_code']; unset($data); diff --git a/src/WsugcBundle/Services/PostService.php b/src/WsugcBundle/Services/PostService.php index 59e34e5..896e674 100755 --- a/src/WsugcBundle/Services/PostService.php +++ b/src/WsugcBundle/Services/PostService.php @@ -61,7 +61,7 @@ class PostService /** * 多语言列表前:将 PostController 组装的复合 content|contains 转为 post_id,避免进入 RepositoryLangInterceptor 时把数组传给 filterByLang。 * 与 PostRepository::_filter 语义对齐:title OR content(多语言映射表)OR keyword_topics_post_id。 - * 若已有 post_id(话题/收藏等),与关键字解析结果取并集并去重;ID 规范为 int,有真实 ID 时剔除 -1 占位。 + * 若已有 post_id(话题/收藏等),与关键字解析结果取交集(AND);两侧经 normalizePostIds 规范为 int,有真实 ID 时剔除 -1 占位;空交集 → [-1]。 * * @param array $filter * @return array @@ -137,12 +137,12 @@ class PostService $hasPostId = array_key_exists('post_id', $filter) && $filter['post_id'] !== '' && $filter['post_id'] !== null; if ($hasPostId) { $existing = $normalizePostIds((array) $filter['post_id']); - // 与「关键字 OR 话题」一致:与已有 post_id(话题/收藏等)取并集,避免 intersect 与多语言 ID 对不上导致永远无数据 + // 与已有 post_id(话题/收藏等)取交集:关键字内部仍为 title OR content OR keyword_topics(UNION),再与 scope 限定 AND if ($existing === []) { $filter['post_id'] = $mergedIds !== [] ? $mergedIds : [-1]; } else { - $union = array_values(array_unique(array_merge($existing, $mergedIds), SORT_REGULAR)); - $filter['post_id'] = $union !== [] ? $union : [-1]; + $intersect = array_values(array_intersect($existing, $mergedIds)); + $filter['post_id'] = $intersect !== [] ? $intersect : [-1]; } } else { $filter['post_id'] = $mergedIds !== [] ? $mergedIds : [-1]; diff --git a/storage/static/Facebook.svg b/storage/static/Facebook.svg new file mode 100644 index 0000000..311e3d5 --- /dev/null +++ b/storage/static/Facebook.svg @@ -0,0 +1,4 @@ + + + + diff --git a/storage/static/Google.svg b/storage/static/Google.svg new file mode 100644 index 0000000..0406992 --- /dev/null +++ b/storage/static/Google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/storage/static/LINE.svg b/storage/static/LINE.svg new file mode 100644 index 0000000..6483190 --- /dev/null +++ b/storage/static/LINE.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/storage/static/apple.svg b/storage/static/apple.svg new file mode 100644 index 0000000..de4b0d1 --- /dev/null +++ b/storage/static/apple.svg @@ -0,0 +1 @@ +Apple \ No newline at end of file diff --git a/tests/MemberSyntheticMobileServiceTest.php b/tests/MemberSyntheticMobileServiceTest.php index ce84731..450bd6a 100644 --- a/tests/MemberSyntheticMobileServiceTest.php +++ b/tests/MemberSyntheticMobileServiceTest.php @@ -41,6 +41,14 @@ class MemberSyntheticMobileServiceTest extends TestCase $row3 = ['mobile' => '10123456789']; MemberSyntheticMobileService::stripPlaceholderMobileForEmailRegisteredMember($row3); - $this->assertSame('10123456789', $row3['mobile']); + $this->assertSame('', $row3['mobile']); + } + + public function testStripShopApiClearsSyntheticForSocialOAuthWithoutLoginEmail(): void + { + $row = ['mobile' => '10258463264', 'region_mobile' => '10258463264']; + MemberSyntheticMobileService::stripSyntheticMobileForShopApi($row); + $this->assertSame('', $row['mobile']); + $this->assertSame('', $row['region_mobile']); } } diff --git a/tests/PopularizeBundle/BrokerageServiceSqlFilterTest.php b/tests/PopularizeBundle/BrokerageServiceSqlFilterTest.php new file mode 100644 index 0000000..f98a38d --- /dev/null +++ b/tests/PopularizeBundle/BrokerageServiceSqlFilterTest.php @@ -0,0 +1,324 @@ + */ + private array $captured = []; + + private bool $executeQueryCalled = false; + + /** + * 最小 Lumen 容器,避免 bootstrap/app.php 触发 Doctrine DB 连接。 + */ + public function createApplication() + { + $app = new \Laravel\Lumen\Application(dirname(__DIR__, 2)); + $app->withFacades(); + + return $app; + } + + protected function setUp(): void + { + parent::setUp(); + $this->captured = []; + $this->executeQueryCalled = false; + $this->mockRegistryConnection(); + $this->mockLog(); + } + + /** + * TC-B-01:恶意 dIds 不得出现在 SQL 字面量中,须经 intval 绑定或滤空后安全返回。 + * #given company_id=1,dIds=['1) OR 1=1--'] + * #when getSalesmanBrokerageCount + * #then SQL 字面量无 OR 1=1;dIds 经 intval 绑定或滤空后安全返回 + */ + public function testTcB01MaliciousDIdsNotInSqlLiteral(): void + { + $params = [ + 'company_id' => 1, + 'dIds' => ['1) OR 1=1--'], + ]; + + $service = new BrokerageService(); + $result = $service->getSalesmanBrokerageCount($params, 10, 1); + + $this->assertIsArray($result); + $this->assertTrue($this->executeQueryCalled, 'executeQuery should be called'); + $this->assertNotEmpty($this->captured); + + $last = end($this->captured); + $this->assertStringNotContainsString('OR 1=1', $last['sql']); + + $paramsBound = $last['params']; + $hasSafeDIdBinding = false; + foreach ($paramsBound as $key => $value) { + if (is_int($value) && $value === 1) { + $hasSafeDIdBinding = true; + } + if (is_array($value) && in_array(1, array_map('intval', $value), true)) { + $hasSafeDIdBinding = true; + } + } + $this->assertTrue( + $hasSafeDIdBinding, + 'dIds should be bound as intval-safe value (1), not raw injection string' + ); + } + + /** + * TC-B-02:恶意 user_id 须绑定为整型,SQL 字面量无 SLEEP。 + * #given company_id=1,user_id="1 OR SLEEP(3)" + * #when getSalesmanBrokerageCount + * #then user_id 为绑定整型;SQL 无 SLEEP + */ + public function testTcB02MaliciousUserIdBoundAsInteger(): void + { + $params = [ + 'company_id' => 1, + 'user_id' => '1 OR SLEEP(3)', + ]; + + $service = new BrokerageService(); + $result = $service->getSalesmanBrokerageCount($params, 10, 1); + + $this->assertIsArray($result); + $this->assertTrue($this->executeQueryCalled); + $this->assertNotEmpty($this->captured); + + $last = end($this->captured); + $this->assertStringNotContainsString('SLEEP', $last['sql']); + $this->assertArrayHasKey('user_id', $last['params']); + $this->assertSame(1, $last['params']['user_id']); + } + + /** + * TC-B-03:无 company_id 时抛 ResourceException,不调用 executeQuery。 + * #given params 无 company_id + * #when getSalesmanBrokerageCount + * #then 抛 ResourceException;不调用 executeQuery + */ + public function testTcB03MissingCompanyIdThrowsWithoutQuery(): void + { + $service = new BrokerageService(); + + try { + $service->getSalesmanBrokerageCount(['user_id' => 1], 10, 1); + $this->fail('Expected ResourceException'); + } catch (ResourceException $e) { + $this->assertFalse($this->executeQueryCalled, 'executeQuery must not be called without company_id'); + } + } + + /** + * TC-B-04:合法 dIds 时 SQL 含 company_id 条件,params 含 company_id 与 dIds。 + * #given company_id=1,dIds=[10,20] + * #when getSalesmanBrokerageCount + * #then SQL 含 bb.company_id = :company_id;params 含 company_id=1 与 dIds + */ + public function testTcB04LegitimateDIdsIncludeCompanyIdBinding(): void + { + $params = [ + 'company_id' => 1, + 'dIds' => [10, 20], + ]; + + $service = new BrokerageService(); + $result = $service->getSalesmanBrokerageCount($params, 10, 1); + + $this->assertIsArray($result); + $this->assertTrue($this->executeQueryCalled); + $this->assertNotEmpty($this->captured); + + $last = end($this->captured); + $this->assertStringContainsString('bb.company_id = :company_id', $last['sql']); + $this->assertSame(1, $last['params']['company_id']); + + $boundDIds = []; + foreach ($last['params'] as $key => $value) { + if (strpos((string) $key, 'distributor_id_') === 0) { + $boundDIds[] = $value; + } + } + sort($boundDIds); + $this->assertSame([10, 20], $boundDIds); + } + + /** + * TC-B-05:恶意 mobile 须绑定,SQL 字面量无破引号片段;空 list 不触发 SalespersonService。 + * #given company_id=1,mobile="a' OR '1'='1" + * #when getSalesmanBrokeragelistsBySql + * #then mobile 绑定;SQL 无 OR '1'='1 字面量;fetch 空时不访问 Salesperson DB + */ + public function testTcB05MaliciousMobileBoundNotInSqlLiteral(): void + { + $params = [ + 'company_id' => 1, + 'mobile' => "a' OR '1'='1", + ]; + + $service = new BrokerageService(); + $result = $service->getSalesmanBrokeragelistsBySql($params, 10, 1); + + $this->assertIsArray($result); + $this->assertTrue($this->executeQueryCalled, 'executeQuery should be called'); + $this->assertNotEmpty($this->captured); + $this->assertGreaterThanOrEqual(2, count($this->captured), 'count + list queries expected'); + + foreach ($this->captured as $capture) { + $this->assertStringNotContainsString("OR '1'='1", $capture['sql']); + $this->assertStringContainsString('bb.company_id = :company_id', $capture['sql']); + $this->assertArrayHasKey('mobile', $capture['params']); + $this->assertSame("a' OR '1'='1", $capture['params']['mobile']); + $this->assertSame(1, $capture['params']['company_id']); + } + + $this->assertSame([], $result['list']); + } + + /** + * TC-B-06:恶意 order_id 须绑定,SQL 无拼接字面量;空 list 不触发 SalespersonService。 + * #given company_id=1,order_id="1' OR '1'='1" + * #when getSalesmanBrokeragelistsBySql + * #then order_id 绑定;SQL 无 OR '1'='1 字面量 + */ + public function testTcB06MaliciousOrderIdBoundNotInSqlLiteral(): void + { + $params = [ + 'company_id' => 1, + 'order_id' => "1' OR '1'='1", + ]; + + $service = new BrokerageService(); + $result = $service->getSalesmanBrokeragelistsBySql($params, 10, 1); + + $this->assertIsArray($result); + $this->assertTrue($this->executeQueryCalled, 'executeQuery should be called'); + $this->assertNotEmpty($this->captured); + $this->assertGreaterThanOrEqual(2, count($this->captured), 'count + list queries expected'); + + foreach ($this->captured as $capture) { + $this->assertStringNotContainsString("OR '1'='1", $capture['sql']); + $this->assertStringContainsString('bb.company_id = :company_id', $capture['sql']); + $this->assertArrayHasKey('order_id', $capture['params']); + $this->assertSame("1' OR '1'='1", $capture['params']['order_id']); + $this->assertSame(1, $capture['params']['company_id']); + } + + $this->assertSame([], $result['list']); + } + + /** + * TC-B-07:非法 groupby 抛 ResourceException,不执行 SQL。 + * #given company_id=1,groupby=evil + * #when getSalesmanBrokerageCountList + * #then 抛 ResourceException;不调用 executeQuery + */ + public function testTcB07InvalidGroupbyThrowsWithoutQuery(): void + { + $service = new BrokerageService(); + + try { + $service->getSalesmanBrokerageCountList([ + 'company_id' => 1, + 'groupby' => 'evil', + ], 10, 1); + $this->fail('Expected ResourceException'); + } catch (ResourceException $e) { + $this->assertFalse($this->executeQueryCalled, 'executeQuery must not be called with invalid groupby'); + } + } + + /** + * TC-B-08:合法 groupby + 恶意 year/month/day 须绑定;SQL 无恶意字面量。 + * #given company_id=1,groupby=distributor_id,year/month/day 恶意串 + * #when getSalesmanBrokerageCountList + * #then groupby 仅白名单标识符;ymd 绑定;SQL 无恶意字面量;fetch 空时不访问 Salesperson DB + */ + public function testTcB08MaliciousYmdBoundWithGroupbyWhitelist(): void + { + $params = [ + 'company_id' => 1, + 'groupby' => 'distributor_id', + 'year' => "2024' OR '1'='1", + 'month' => "2024-07' OR '1'='1", + 'day' => "2024-07-30' OR '1'='1", + ]; + + $service = new BrokerageService(); + $result = $service->getSalesmanBrokerageCountList($params, 10, 1); + + $this->assertIsArray($result); + $this->assertTrue($this->executeQueryCalled, 'executeQuery should be called'); + $this->assertNotEmpty($this->captured); + + $last = end($this->captured); + $this->assertStringNotContainsString("OR '1'='1", $last['sql']); + $this->assertStringContainsString('bb.company_id = :company_id', $last['sql']); + $this->assertSame(1, $last['params']['company_id']); + $this->assertStringContainsString('oo.distributor_id', $last['sql']); + $this->assertStringNotContainsString("oo.2024' OR '1'='1", $last['sql']); + $this->assertArrayHasKey('day', $last['params']); + $this->assertSame("2024-07-30' OR '1'='1", $last['params']['day']); + $this->assertSame([], $result); + } + + private function mockRegistryConnection(): void + { + $self = $this; + $conn = $this->getMockBuilder(\Doctrine\DBAL\Connection::class) + ->disableOriginalConstructor() + ->getMock(); + $conn->method('executeQuery')->willReturnCallback( + function ($sql, $params = [], $types = []) use ($self) { + $self->executeQueryCalled = true; + $self->captured[] = [ + 'sql' => (string) $sql, + 'params' => $params, + 'types' => $types, + ]; + $result = $this->getMockBuilder(\stdClass::class) + ->addMethods(['fetch', 'fetchAll', 'fetchAssociative', 'fetchAllAssociative']) + ->getMock(); + $result->method('fetch')->willReturn([]); + $result->method('fetchAll')->willReturn([]); + $result->method('fetchAssociative')->willReturn(false); + $result->method('fetchAllAssociative')->willReturn([]); + + return $result; + } + ); + + $mockRepo = $this->getMockBuilder(\stdClass::class)->addMethods(['create'])->getMock(); + $mockManager = $this->getMockBuilder(\stdClass::class) + ->addMethods(['getRepository']) + ->getMock(); + $mockManager->method('getRepository')->willReturn($mockRepo); + + $mockRegistry = $this->getMockBuilder(\stdClass::class) + ->addMethods(['getConnection', 'getManager']) + ->getMock(); + $mockRegistry->method('getConnection')->with('default')->willReturn($conn); + $mockRegistry->method('getManager')->with('default')->willReturn($mockManager); + + $this->app->instance('registry', $mockRegistry); + } + + private function mockLog(): void + { + $log = $this->getMockBuilder(\stdClass::class)->addMethods(['debug'])->getMock(); + $log->method('debug')->willReturn(null); + $this->app->instance('log', $log); + } +} diff --git a/tests/PopularizeBundle/ControllerValidationTest.php b/tests/PopularizeBundle/ControllerValidationTest.php new file mode 100644 index 0000000..78decba --- /dev/null +++ b/tests/PopularizeBundle/ControllerValidationTest.php @@ -0,0 +1,214 @@ +withFacades(); + $app->instance('path.lang', $app->basePath('resources/lang')); + $app->register(\Illuminate\Translation\TranslationServiceProvider::class); + $app->register(\Illuminate\Validation\ValidationServiceProvider::class); + + return $app; + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * TC-C-01:brokerageCount 传入非整数 distributor_id 应校验失败,不进入 BrokerageService。 + * #given Api brokerageCount,distributor_id 非整数 + * #when validator + * #then 校验失败,不进 Service + */ + public function testTcC01BrokerageCountRejectsNonIntegerDistributorId(): void + { + $this->bindAuth(1); + $this->bindResponseFactory(); + $this->bindLog(); + $this->bindPromoterCountOverload(); + + $brokerageServiceCalled = false; + $brokerageMock = Mockery::mock('overload:PopularizeBundle\Services\BrokerageService'); + $brokerageMock->shouldReceive('getSalesmanBrokerageCount')->andReturnUsing( + function () use (&$brokerageServiceCalled): array { + $brokerageServiceCalled = true; + + return []; + } + ); + + $request = Request::create('/popularize/brokerage/count', 'GET', [ + 'distributor_id' => '1abc', + ]); + + $controller = new BrokerageController(); + + try { + $controller->brokerageCount($request); + $this->fail('Expected ResourceException when distributor_id is not an integer'); + } catch (ResourceException $e) { + $this->assertNotEmpty($e->getMessage()); + $this->assertFalse($brokerageServiceCalled, 'BrokerageService must not be called after controller validation failure'); + } + } + + /** + * TC-C-02:getBrokerageList 传入非整数 user_id 应校验失败,不进入 BrokerageService。 + * #given Api getBrokerageList,user_id 非整数 + * #when validator + * #then 校验失败 + */ + public function testTcC02GetBrokerageListRejectsNonIntegerUserId(): void + { + $this->bindAuth(1); + $this->bindResponseFactory(); + $this->bindLog(); + + $brokerageServiceCalled = false; + $brokerageMock = Mockery::mock('overload:PopularizeBundle\Services\BrokerageService'); + $brokerageMock->shouldReceive('getBrokerageDbList')->andReturnUsing( + function () use (&$brokerageServiceCalled): array { + $brokerageServiceCalled = true; + + return ['total_count' => 0, 'list' => []]; + } + ); + $brokerageMock->shouldReceive('getSalesmanBrokeragelistsBySql')->never(); + + $request = Request::create('/popularize/brokerage/logs', 'GET', [ + 'page' => 1, + 'pageSize' => 10, + 'user_id' => 'abc', + ]); + + $controller = new BrokerageController(); + + try { + $controller->getBrokerageList($request); + $this->fail('Expected ResourceException when user_id is not an integer'); + } catch (ResourceException $e) { + $this->assertNotEmpty($e->getMessage()); + $this->assertFalse($brokerageServiceCalled, 'BrokerageService must not be called after controller validation failure'); + } + } + + /** + * TC-C-03:brokagestaticlist 传入非法 groupby 应在 Controller 层拒绝,不进入 BrokerageService。 + * #given Front/Salesperson brokagestaticlist,groupby=evil + * #when validator 或 Service + * #then 拒绝(Controller 层:Service 不应被调用) + */ + public function testTcC03BrokageStaticListRejectsInvalidGroupbyBeforeService(): void + { + $this->bindResponseFactory(); + $this->bindLog(); + + $brokerageServiceCalled = false; + $brokerageMock = Mockery::mock('overload:PopularizeBundle\Services\BrokerageService'); + $brokerageMock->shouldReceive('getSalesmanBrokerageCountList')->andReturnUsing( + function () use (&$brokerageServiceCalled): array { + $brokerageServiceCalled = true; + + return []; + } + ); + + $request = Request::create('/h5app/wxapp/salespersonadmin/brokagestaticlist', 'GET', [ + 'groupby' => 'evil', + 'page' => 1, + 'pageSize' => 10, + 'distributor_id' => 1, + ]); + $request->merge([ + 'auth' => [ + 'company_id' => 1, + 'user_id' => 9, + ], + ]); + + $controller = new ShopSalespersonController(); + + try { + $controller->brokagestaticlist($request); + $this->fail('Expected ResourceException when groupby is invalid'); + } catch (ResourceException $e) { + $this->assertNotEmpty($e->getMessage()); + $this->assertFalse($brokerageServiceCalled, 'BrokerageService must not be called after controller validation failure'); + } + } + + private function bindAuth(int $companyId): void + { + $user = Mockery::mock(); + $user->shouldReceive('get')->with('company_id')->andReturn($companyId); + $authGuard = Mockery::mock(); + $authGuard->shouldReceive('user')->andReturn($user); + $this->app->instance('auth', $authGuard); + } + + private function bindResponseFactory(): void + { + $factory = Mockery::mock(Factory::class); + $factory->shouldReceive('array')->andReturnUsing(static fn (array $payload): array => $payload); + $this->app->instance(Factory::class, $factory); + } + + private function bindLog(): void + { + $log = $this->getMockBuilder(\stdClass::class)->addMethods(['debug', 'info'])->getMock(); + $log->method('debug')->willReturn(null); + $log->method('info')->willReturn(null); + $this->app->instance('log', $log); + } + + private function bindPromoterCountOverload(): void + { + $promoterMock = Mockery::mock('overload:PopularizeBundle\Services\PromoterCountService'); + $promoterMock->shouldReceive('getCount')->andReturn([ + 'payedRebate' => 0, + 'itemTotalPrice' => 0, + 'cashWithdrawalRebate' => 0, + 'noCloseRebate' => 0, + 'rebateTotal' => 0, + 'freezeCashWithdrawalRebate' => 0, + 'pointTotal' => 0, + ]); + $promoterMock->shouldReceive('getPromoterCount')->andReturn([ + 'payedRebate' => 0, + 'itemTotalPrice' => 0, + 'cashWithdrawalRebate' => 0, + 'noCloseRebate' => 0, + 'rebateTotal' => 0, + 'freezeCashWithdrawalRebate' => 0, + 'pointTotal' => 0, + ]); + } +} diff --git a/tests/PopularizeBundle/PromoterServiceSqlFilterTest.php b/tests/PopularizeBundle/PromoterServiceSqlFilterTest.php new file mode 100644 index 0000000..bb16bc1 --- /dev/null +++ b/tests/PopularizeBundle/PromoterServiceSqlFilterTest.php @@ -0,0 +1,230 @@ + */ + private array $captured = []; + + private bool $executeQueryCalled = false; + + public function createApplication() + { + $app = new \Laravel\Lumen\Application(dirname(__DIR__, 2)); + $app->withFacades(); + + return $app; + } + + protected function setUp(): void + { + parent::setUp(); + $this->captured = []; + $this->executeQueryCalled = false; + $this->mockRegistryConnection(); + $this->mockLog(); + } + + /** + * TC-P-01:恶意 date 须绑定,SQL 无破引号,company_id 来自 authInfo。 + * #given authInfo company_id=1,params date 恶意串 + * #when getSalesmanCount + * #then date 绑定;SQL 无破引号;:company_id=1 + */ + public function testTcP01MaliciousDateBoundWithCompanyIdFromAuth(): void + { + $authInfo = ['user_id' => 9, 'company_id' => 1]; + $params = [ + 'datetype' => 'm', + 'date' => "2024-01' OR '1'='1", + ]; + + $service = new PromoterService(); + $result = $service->getSalesmanCount($authInfo, $params); + + $this->assertIsArray($result); + $this->assertTrue($this->executeQueryCalled, 'executeQuery should be called'); + $this->assertNotEmpty($this->captured); + + $last = end($this->captured); + $this->assertStringNotContainsString("OR '1'='1", $last['sql']); + $this->assertStringContainsString('bb.company_id = :company_id', $last['sql']); + $this->assertSame(1, $last['params']['company_id']); + $this->assertArrayHasKey('date_val', $last['params']); + $this->assertSame("2024-01' OR '1'='1", $last['params']['date_val']); + } + + /** + * TC-P-02:恶意 distributor_id 须绑定为整型。 + * #given authInfo company_id=1,distributor_id 恶意串 + * #when getSalesmanStatic + * #then distributor_id 绑定整型;SQL 无 OR 1=1 字面量 + */ + public function testTcP02MaliciousDistributorIdBoundAsInteger(): void + { + $authInfo = ['user_id' => 9, 'company_id' => 1]; + $params = [ + 'distributor_id' => '1 OR 1=1', + ]; + + $service = new PromoterService(); + $result = $service->getSalesmanStatic($authInfo, $params); + + $this->assertIsArray($result); + $this->assertTrue($this->executeQueryCalled, 'executeQuery should be called'); + $this->assertNotEmpty($this->captured); + + $brokerageQuery = $this->captured[0]; + $this->assertStringNotContainsString('OR 1=1', $brokerageQuery['sql']); + $this->assertArrayHasKey('distributor_id', $brokerageQuery['params']); + $this->assertSame(1, $brokerageQuery['params']['distributor_id']); + $this->assertStringContainsString('bb.company_id = :company_id', $brokerageQuery['sql']); + $this->assertSame(1, $brokerageQuery['params']['company_id']); + } + + /** + * TC-P-03:无 company_id 时抛 ResourceException,不执行 SQL。 + * #given authInfo 无 company_id,params 空 + * #when getSalesmanCount + * #then 抛 ResourceException;不调用 executeQuery + */ + public function testTcP03MissingCompanyIdThrowsWithoutQuery(): void + { + $authInfo = ['user_id' => 9]; + $params = []; + + $service = new PromoterService(); + + try { + $service->getSalesmanCount($authInfo, $params); + $this->fail('Expected ResourceException'); + } catch (ResourceException $e) { + $this->assertFalse($this->executeQueryCalled, 'executeQuery must not be called without company_id'); + } + } + + /** + * TC-P-04:合法 datetype/tab 时 SQL 绑定与白名单;S6 仅接收占位符+bind。 + * #given authInfo company_id=1,合法 datetype/tab/date + * #when getSalesmanStatic + * #then SQL 含 :company_id;tab 白名单;S6 无用户字面量 SQL 片段 + */ + public function testTcP04LegitimateDatetypeTabUsesBindingAndS6Placeholders(): void + { + $authInfo = ['user_id' => 9, 'company_id' => 1]; + $params = [ + 'datetype' => 'm', + 'date' => '2024-01', + 'tab' => 'lv1', + ]; + + $service = new PromoterService(); + $result = $service->getSalesmanStatic($authInfo, $params); + + $this->assertIsArray($result); + $this->assertTrue($this->executeQueryCalled); + $this->assertGreaterThanOrEqual(2, count($this->captured), 'brokerage + promoter queries expected'); + + $brokerageQuery = $this->captured[0]; + $this->assertStringContainsString('bb.company_id = :company_id', $brokerageQuery['sql']); + $this->assertSame(1, $brokerageQuery['params']['company_id']); + $this->assertArrayHasKey('date_val', $brokerageQuery['params']); + $this->assertSame('2024-01', $brokerageQuery['params']['date_val']); + $this->assertStringContainsString("'first_level'", $brokerageQuery['sql']); + $this->assertStringNotContainsString('2024-01', $brokerageQuery['sql']); + + $promoterQuery = $this->captured[1]; + $this->assertStringContainsString('popularize_promoter', $promoterQuery['sql']); + $this->assertStringContainsString(':date_val', $promoterQuery['sql']); + $this->assertStringNotContainsString("'2024-01'", $promoterQuery['sql']); + $this->assertSame(1, $promoterQuery['params']['company_id']); + $this->assertSame('2024-01', $promoterQuery['params']['date_val']); + } + + /** + * TC-P-05:getSalesmanStatic→S6 含 popularize_promoter 与 company_id 绑定。 + * #given authInfo company_id=1,合法 date + * #when getSalesmanStatic + * #then S6 SQL 含 popularize_promoter 与 bb.company_id = :company_id;bind 含 company_id + */ + public function testTcP05SalesPromotersStaticIncludesCompanyIdBinding(): void + { + $authInfo = ['user_id' => 9, 'company_id' => 1]; + $params = [ + 'datetype' => 'd', + 'date' => '2024-01-15', + ]; + + $service = new PromoterService(); + $result = $service->getSalesmanStatic($authInfo, $params); + + $this->assertIsArray($result); + $this->assertGreaterThanOrEqual(2, count($this->captured)); + + $promoterQuery = end($this->captured); + $this->assertStringContainsString('popularize_promoter', $promoterQuery['sql']); + $this->assertStringContainsString('bb.company_id = :company_id', $promoterQuery['sql']); + $this->assertSame(1, $promoterQuery['params']['company_id']); + $this->assertArrayHasKey('date_val', $promoterQuery['params']); + $this->assertSame('2024-01-15', $promoterQuery['params']['date_val']); + $this->assertStringNotContainsString("'2024-01-15'", $promoterQuery['sql']); + } + + private function mockRegistryConnection(): void + { + $self = $this; + $conn = $this->getMockBuilder(\Doctrine\DBAL\Connection::class) + ->disableOriginalConstructor() + ->getMock(); + $conn->method('executeQuery')->willReturnCallback( + function ($sql, $params = [], $types = []) use ($self) { + $self->executeQueryCalled = true; + $self->captured[] = [ + 'sql' => (string) $sql, + 'params' => $params, + 'types' => $types, + ]; + $result = $this->getMockBuilder(\stdClass::class) + ->addMethods(['fetch', 'fetchAll', 'fetchAssociative', 'fetchAllAssociative']) + ->getMock(); + $result->method('fetch')->willReturn([]); + $result->method('fetchAll')->willReturn([]); + $result->method('fetchAssociative')->willReturn(false); + $result->method('fetchAllAssociative')->willReturn([]); + + return $result; + } + ); + + $mockRepo = $this->getMockBuilder(\stdClass::class)->addMethods(['create'])->getMock(); + $mockManager = $this->getMockBuilder(\stdClass::class) + ->addMethods(['getRepository']) + ->getMock(); + $mockManager->method('getRepository')->willReturn($mockRepo); + + $mockRegistry = $this->getMockBuilder(\stdClass::class) + ->addMethods(['getConnection', 'getManager']) + ->getMock(); + $mockRegistry->method('getConnection')->with('default')->willReturn($conn); + $mockRegistry->method('getManager')->with('default')->willReturn($mockManager); + + $this->app->instance('registry', $mockRegistry); + } + + private function mockLog(): void + { + $log = $this->getMockBuilder(\stdClass::class)->addMethods(['debug'])->getMock(); + $log->method('debug')->willReturn(null); + $this->app->instance('log', $log); + } +} diff --git a/tests/WsugcBundle/PostListSearchFilterNormalizeTest.php b/tests/WsugcBundle/PostListSearchFilterNormalizeTest.php new file mode 100644 index 0000000..43bd660 --- /dev/null +++ b/tests/WsugcBundle/PostListSearchFilterNormalizeTest.php @@ -0,0 +1,361 @@ + */ + private static array $titleIdsReturn = []; + + /** @var array */ + private static array $contentIdsReturn = []; + + protected function setUp(): void + { + parent::setUp(); + self::$titleIdsReturn = []; + self::$contentIdsReturn = []; + $this->bindRegistryMock(); + $this->bindLangModOverload(); + } + + protected function tearDown(): void + { + \Mockery::close(); + parent::tearDown(); + } + + /** + * TODO-1 smoke:Reflection 可调用 private normalize 方法。 + */ + public function testSmokeInvokeNormalizeEarlyReturnWithoutContentContains(): void + { + #given filter 无 content|contains + $filter = ['post_id' => [101, 102], 'company_id' => 1]; + + #when + $result = $this->invokeNormalize($filter); + + #then 早退,filter 原样 + $this->assertSame($filter, $result); + } + + /** + * TC-06(AC-1):existing=[101,102],mergedIds=[102,103] → intersect 得 [102]。 + */ + public function testTC06TopicsAndContentIntersectReturnsMatchingPostId(): void + { + #given 话题限定 post_id,content 在多语言 title/content 命中 102、103 + self::$titleIdsReturn = [102]; + self::$contentIdsReturn = [103]; + $filter = $this->buildFilterWithContentContains('keyword', [], [101, 102]); + + #when + $result = $this->invokeNormalize($filter); + + #then 取交集,仅返回 102 + $this->assertArrayNotHasKey('content|contains', $result); + $this->assertSame([102], $result['post_id']); + } + + /** + * TC-07(AC-2):existing=[101,102],mergedIds=[103] → 关键字在话题外,post_id=[-1]。 + */ + public function testTC07KeywordOutsideTopicsReturnsEmptySentinel(): void + { + #given content 仅命中 103,不在话题 post_id 内 + self::$titleIdsReturn = [103]; + self::$contentIdsReturn = []; + $filter = $this->buildFilterWithContentContains('keyword', [], [101, 102]); + + #when + $result = $this->invokeNormalize($filter); + + #then 交集为空 → [-1] + $this->assertSame([-1], $result['post_id']); + } + + /** + * TC-08(AC-3):existing=[101,102],mergedIds=[] → 关键字无命中,post_id=[-1]。 + */ + public function testTC08NoKeywordMatchReturnsEmptySentinelNotTopicFull(): void + { + #given 多语言与 keyword_topics 均无命中 + self::$titleIdsReturn = []; + self::$contentIdsReturn = []; + $filter = $this->buildFilterWithContentContains('nohit', [], [101, 102]); + + #when + $result = $this->invokeNormalize($filter); + + #then 非话题全量,而是 [-1] + $this->assertSame([-1], $result['post_id']); + } + + /** + * TC-04(AC-4):仅 content,无 existing post_id → post_id = 关键字 OR 合并结果。 + */ + public function testTC04ContentOnlySetsMergedPostIdsWithoutIntersect(): void + { + #given 无 post_id,filterByLang title/content 分别命中 101、102 + self::$titleIdsReturn = [101]; + self::$contentIdsReturn = [102]; + $filter = $this->buildFilterWithContentContains('keyword'); + + #when + $result = $this->invokeNormalize($filter); + + #then 直接写入 mergedIds,不走 intersect 分支 + $this->assertSame([101, 102], $result['post_id']); + } + + /** + * TC-05(AC-5):仅 post_id,无 content|contains → filter 不变。 + */ + public function testTC05TopicsOnlyLeavesFilterUnchanged(): void + { + #given 仅 topics 写入的 post_id + $filter = ['post_id' => [101, 102], 'company_id' => 1]; + + #when + $result = $this->invokeNormalize($filter); + + #then 早退,filter 原样 + $this->assertSame($filter, $result); + } + + /** + * TC-01(AC-10):无 content|contains 键 → 早退。 + */ + public function testTC01MissingContentContainsKeyEarlyReturn(): void + { + #given filter 无 content|contains + $filter = ['post_id' => [1], 'company_id' => 1]; + + #when + $result = $this->invokeNormalize($filter); + + #then 原样返回 + $this->assertSame($filter, $result); + } + + /** + * TC-02(AC-10):content|contains 为字符串(Admin 形态)→ 早退。 + */ + public function testTC02ContentContainsAsStringEarlyReturn(): void + { + #given Admin 形态字符串 + $filter = [ + 'content|contains' => 'plain string', + 'post_id' => [101], + 'company_id' => 1, + ]; + + #when + $result = $this->invokeNormalize($filter); + + #then 原样返回 + $this->assertSame($filter, $result); + } + + /** + * TC-03(AC-10):array 缺 keyword_topics_post_id → 早退。 + */ + public function testTC03MissingKeywordTopicsPostIdEarlyReturn(): void + { + #given content|contains 缺 keyword_topics_post_id + $filter = [ + 'content|contains' => ['content' => 'kw'], + 'post_id' => [101], + 'company_id' => 1, + ]; + + #when + $result = $this->invokeNormalize($filter); + + #then 原样返回 + $this->assertSame($filter, $result); + } + + /** + * TC-09(AC-6):existing=[-1],mergedIds=[101] → post_id=[-1]。 + */ + public function testTC09EmptyTopicsWithKeywordMatchReturnsEmptySentinel(): void + { + #given 话题无笔记 post_id=[-1],content 有命中 + self::$titleIdsReturn = [101]; + self::$contentIdsReturn = []; + $filter = $this->buildFilterWithContentContains('keyword', [], [-1]); + + #when + $result = $this->invokeNormalize($filter); + + #then intersect([-1],[101]) 为空 → [-1] + $this->assertSame([-1], $result['post_id']); + } + + /** + * TC-10(AC-7):existing 为 string,mergedIds 为 int → normalize 后 intersect 得 [102]。 + */ + public function testTC10StringExistingPostIdsIntersectWithIntMergedIds(): void + { + #given existing 字符串 ID + self::$titleIdsReturn = [102]; + self::$contentIdsReturn = [103]; + $filter = $this->buildFilterWithContentContains('keyword', [], ['101', '102']); + + #when + $result = $this->invokeNormalize($filter); + + #then normalizePostIds 后 intersect 得 [102] + $this->assertSame([102], $result['post_id']); + } + + /** + * TC-11(AC-8):keyword_topics 命中 102,但 post 不在 topics[] → post_id=[-1]。 + */ + public function testTC11KeywordTopicMatchOutsideScopeReturnsEmptySentinel(): void + { + #given 话题限定 [101],keyword_topics_post_id 命中 102 + self::$titleIdsReturn = []; + self::$contentIdsReturn = []; + $filter = $this->buildFilterWithContentContains('', [102], [101]); + + #when + $result = $this->invokeNormalize($filter); + + #then intersect 空 → [-1] + $this->assertSame([-1], $result['post_id']); + } + + /** + * TC-12(AC-9):favorite existing=[201,202],mergedIds=[202,203] → [202]。 + */ + public function testTC12FavoriteScopeAndContentIntersect(): void + { + #given 收藏集合 post_id + self::$titleIdsReturn = [202]; + self::$contentIdsReturn = [203]; + $filter = $this->buildFilterWithContentContains('keyword', [], [201, 202]); + + #when + $result = $this->invokeNormalize($filter); + + #then intersect 得 [202] + $this->assertSame([202], $result['post_id']); + } + + /** + * TC-13:mergedIds 含 -1 与正数 ID,无 existing → normalize 剔除 -1 后 [101]。 + */ + public function testTC13NormalizeStripsSentinelWhenPositiveIdsExist(): void + { + #given 无 existing,title 返回 [-1,101] + self::$titleIdsReturn = [-1, 101]; + self::$contentIdsReturn = []; + $filter = $this->buildFilterWithContentContains('keyword'); + + #when + $result = $this->invokeNormalize($filter); + + #then normalizePostIds 剔除 -1 + $this->assertSame([101], $result['post_id']); + } + + /** + * TC-14(AC-3):intersect 结果为空 → 输出 [-1]。 + */ + public function testTC14EmptyIntersectOutputsSentinel(): void + { + #given existing 与 mergedIds 无交集 + self::$titleIdsReturn = [999]; + self::$contentIdsReturn = []; + $filter = $this->buildFilterWithContentContains('keyword', [], [101, 102]); + + #when + $result = $this->invokeNormalize($filter); + + #then + $this->assertSame([-1], $result['post_id']); + } + + private function bindRegistryMock(): void + { + $mockRepo = new \stdClass(); + $mockRepo->table = 'wsugc_post'; + $mockRepo->langField = ['title', 'address', 'content']; + + $mockManager = $this->getMockBuilder(\stdClass::class) + ->addMethods(['getRepository']) + ->getMock(); + $mockManager->method('getRepository') + ->with(Post::class) + ->willReturn($mockRepo); + + $mockRegistry = $this->getMockBuilder(\stdClass::class) + ->addMethods(['getManager']) + ->getMock(); + $mockRegistry->method('getManager') + ->with('default') + ->willReturn($mockManager); + + $this->app->instance('registry', $mockRegistry); + } + + private function bindLangModOverload(): void + { + $mock = \Mockery::mock('overload:' . CommonLangModService::class); + $mock->shouldReceive('getLang')->andReturn('zh'); + $mock->shouldReceive('filterByLang')->andReturnUsing( + function (string $lang, string $field, string $content, string $tableName, $companyId = 0): array { + if (strpos($field, 'title') === 0) { + return self::$titleIdsReturn; + } + if (strpos($field, 'content') === 0) { + return self::$contentIdsReturn; + } + + return []; + } + ); + } + + /** + * @param array $postIds + * @return array + */ + private function buildFilterWithContentContains( + string $searchText, + array $keywordTopicsPostIds = [], + array $postIds = [] + ): array { + $filter = [ + 'company_id' => 1, + 'content|contains' => [ + 'content' => $searchText, + 'keyword_topics_post_id' => $keywordTopicsPostIds, + ], + ]; + if ($postIds !== []) { + $filter['post_id'] = $postIds; + } + + return $filter; + } + + private function invokeNormalize(array $filter): array + { + $service = new PostService(); + $ref = new \ReflectionClass(PostService::class); + $method = $ref->getMethod('normalizePostListFilterForMultilang'); + $method->setAccessible(true); + + return $method->invoke($service, $filter); + } +}