diff --git a/.env.example b/.env.example index df02ca6..d70a78a 100755 --- a/.env.example +++ b/.env.example @@ -54,3 +54,7 @@ PUSHER_APP_CLUSTER=mt1 MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" + +# Match WeChat mini-program message push settings +WECHAT_CONTENT_SECURITY_TOKEN= +WECHAT_CONTENT_SECURITY_AES_KEY= diff --git a/app/Http/Controllers/Mobile/ContentSecurityController.php b/app/Http/Controllers/Mobile/ContentSecurityController.php new file mode 100644 index 0000000..bc50a92 --- /dev/null +++ b/app/Http/Controllers/Mobile/ContentSecurityController.php @@ -0,0 +1,26 @@ +success($checks->result($this->getUserId(), (int) request('check_id'))); } + catch (\InvalidArgumentException $e) { return $this->fail([ResponseCode::ERROR_BUSINESS, $e->getMessage()]); } + } + public function callback(AvatarCheck $checks) { + abort_unless(config('content-security.token') && config('content-security.aes_key'), 503); + $app = miniProgramApp(['token' => config('content-security.token'), 'aes_key' => config('content-security.aes_key'), 'response_type' => 'array']); + // Explicitly bind the current request, including JSON/encrypted event bodies. + $app->rebind('request', request()); + $app->server->forceValidate(); + $app->server->push(function ($message) use ($checks) { $checks->receive($message); }); + try { + $app->server->validate(); + if (request()->isMethod('get')) return response((string) request('echostr', '')); + return $app->server->serve(); + } + catch (\EasyWeChat\Kernel\Exceptions\BadRequestException $e) { abort(400, 'Invalid WeChat callback'); } + } +} diff --git a/app/Http/Controllers/Mobile/UploadController.php b/app/Http/Controllers/Mobile/UploadController.php index abc9afc..6937d18 100755 --- a/app/Http/Controllers/Mobile/UploadController.php +++ b/app/Http/Controllers/Mobile/UploadController.php @@ -32,6 +32,11 @@ class UploadController extends CommonController if ($fileSize >= 50 * 1024 *1024) { return $this->fail([ResponseCode::ERROR_BUSINESS, '文件必须小于50M']); } + if ($request->input('purpose') === 'avatar') { + if ($fileSize > 1024 * 1024 || !in_array($file->getMimeType(), ['image/jpeg', 'image/png'], true)) { + return $this->fail([ResponseCode::ERROR_BUSINESS, '头像请使用1MB以内的JPG或PNG图片']); + } + } $originalName = $request->input('original_name'); if (!is_string($originalName) || trim($originalName) === '') $originalName = $file->getClientOriginalName(); //过滤文件后缀 @@ -60,6 +65,10 @@ class UploadController extends CommonController ]; $id = Upload::insertGetId($list); $uploadFile = Upload::find($id); + if ($request->input('purpose') === 'avatar') { + try { return $this->success(app(\App\Services\ContentSecurity\AvatarCheck::class)->submit($this->getUser(), $uploadFile)); } + catch (\InvalidArgumentException $e) { return $this->fail([ResponseCode::ERROR_BUSINESS, $e->getMessage()]); } + } return $this->success($uploadFile); } else { return $this->fail([ResponseCode::ERROR_BUSINESS, '文件格式错误']); diff --git a/app/Http/Controllers/Mobile/UserController.php b/app/Http/Controllers/Mobile/UserController.php index 6d48f28..c2af11a 100755 --- a/app/Http/Controllers/Mobile/UserController.php +++ b/app/Http/Controllers/Mobile/UserController.php @@ -168,6 +168,10 @@ class UserController extends CommonController { $all = \request()->except(['id', 'mobile', 'openid', 'survey_id']); $model = User::find($this->getUserId()); + if (array_key_exists('headimgurl', $all) && $all['headimgurl'] !== $model->headimgurl) { + try { app(\App\Services\ContentSecurity\AvatarCheck::class)->assertApproved($model->id, (string) $all['headimgurl']); } + catch (\InvalidArgumentException $e) { return $this->fail([ResponseCode::ERROR_BUSINESS, $e->getMessage()]); } + } $oldCompanyName = $model->company_name; if (isset($all['password'])) { // 判断旧密码是否正确 diff --git a/app/Services/ContentSecurity/AvatarCheck.php b/app/Services/ContentSecurity/AvatarCheck.php new file mode 100644 index 0000000..aa4f397 --- /dev/null +++ b/app/Services/ContentSecurity/AvatarCheck.php @@ -0,0 +1,66 @@ +openid) throw new InvalidArgumentException('请重新进入小程序后再试'); + $id = DB::table('content_security_checks')->insertGetId([ + 'user_id' => $user->id, 'upload_id' => $upload->id, 'url' => $upload->url, + 'status' => 'pending', 'created_at' => now(), 'updated_at' => now(), + ]); + try { + // The SDK convenience method uses v1; explicitly send all v2 parameters. + $result = miniProgramApp()->content_security->httpPostJson('wxa/media_check_async', [ + 'media_url' => $upload->url, 'media_type' => 2, 'version' => 2, + 'scene' => 1, 'openid' => $user->openid, + ]); + if ((int) ($result['errcode'] ?? -1) !== 0 || empty($result['trace_id'])) throw new \RuntimeException('WeChat media check submission failed'); + DB::table('content_security_checks')->where('id', $id)->update(['trace_id' => $result['trace_id'], 'updated_at' => now()]); + } catch (\Throwable $e) { + DB::table('content_security_checks')->where('id', $id)->update(['status' => 'error', 'updated_at' => now()]); + Log::warning('Avatar check submission failed', ['check_id' => $id, 'exception' => get_class($e)]); + throw new InvalidArgumentException('内容检测暂不可用,请稍后重新上传'); + } + return ['check_id' => $id, 'status' => 'pending']; + } + + public function result(int $userId, int $id): array + { + $row = DB::table('content_security_checks')->where('id', $id)->where('user_id', $userId)->first(); + if (!$row) throw new InvalidArgumentException('检测记录不存在'); + $status = $row->status; + if ($status === 'pending' && strtotime($row->created_at) < time() - 600) $status = 'error'; + return ['check_id' => $row->id, 'status' => $status, 'url' => $status === 'pass' ? $row->url : null]; + } + + public function receive(array $message): void + { + if (($message['Event'] ?? '') !== 'wxa_media_check') return; + $trace = $message['trace_id'] ?? ''; + if (!$trace) return; + if (!DB::table('content_security_checks')->where('trace_id', $trace)->exists()) { + // A callback may arrive before the submission response is stored; request a retry. + throw new \RuntimeException('Unknown media check trace'); + } + $suggest = $message['result']['suggest'] ?? ''; + $error = (int) ($message['errcode'] ?? -1); + $status = $error !== 0 ? 'error' : ($suggest === 'pass' ? 'pass' : 'reject'); + // Only a signed callback can reach here. Duplicates cannot overwrite a final decision. + DB::table('content_security_checks')->where('trace_id', $trace)->where('status', 'pending') + ->update(['status' => $status, 'suggest' => $suggest, 'error_code' => $error, 'updated_at' => now()]); + } + + public function assertApproved(int $userId, string $url): void + { + $approved = DB::table('content_security_checks')->where('user_id', $userId)->where('url', $url) + ->where('status', 'pass')->exists(); + if (!$approved) throw new InvalidArgumentException('头像尚未通过内容检测,请重新上传并等待检测通过'); + } +} diff --git a/config/content-security.php b/config/content-security.php new file mode 100644 index 0000000..020a29d --- /dev/null +++ b/config/content-security.php @@ -0,0 +1,5 @@ + env('WECHAT_CONTENT_SECURITY_TOKEN'), + 'aes_key' => env('WECHAT_CONTENT_SECURITY_AES_KEY'), +]; diff --git a/database/migrations/2026_10_23_100000_create_content_security_checks.php b/database/migrations/2026_10_23_100000_create_content_security_checks.php new file mode 100644 index 0000000..57b255d --- /dev/null +++ b/database/migrations/2026_10_23_100000_create_content_security_checks.php @@ -0,0 +1,12 @@ +id(); $t->unsignedBigInteger('user_id')->index(); $t->unsignedBigInteger('upload_id')->index(); + $t->string('trace_id')->nullable()->unique(); $t->string('status')->default('pending'); + $t->text('url'); $t->string('suggest')->nullable(); $t->integer('error_code')->nullable(); $t->timestamps(); + }); } + public function down() { Schema::dropIfExists('content_security_checks'); } +}; diff --git a/routes/api.php b/routes/api.php index fdbadc7..9e9360f 100755 --- a/routes/api.php +++ b/routes/api.php @@ -18,6 +18,8 @@ Route::middleware('auth:sanctum')->get('/user', function (Request $request) { return $request->user(); }); +Route::match(['get', 'post'], 'wechat/content-security/callback', [\App\Http\Controllers\Mobile\ContentSecurityController::class, 'callback']); + Route::get('test', [\App\Http\Controllers\Admin\OtherController::class, "test"]); // 车辆进出场 @@ -423,6 +425,7 @@ Route::group(["namespace" => "Mobile", "prefix" => "mobile"], function () { // 公司详情 Route::get('other/company-detail', [\App\Http\Controllers\Mobile\OtherController::class, "companyDetail"]); // 其他 + Route::get('content-security/avatar-status', [\App\Http\Controllers\Mobile\ContentSecurityController::class, 'avatarStatus']); Route::post('upload-file', [\App\Http\Controllers\Mobile\UploadController::class, "uploadFile"]); // 用户信息 Route::get('user/account-login', [\App\Http\Controllers\Mobile\UserController::class, "accountLogin"]); diff --git a/tests/Feature/AvatarContentSecurityTest.php b/tests/Feature/AvatarContentSecurityTest.php new file mode 100644 index 0000000..ceb7bbf --- /dev/null +++ b/tests/Feature/AvatarContentSecurityTest.php @@ -0,0 +1,92 @@ + 'sqlite', 'database.connections.sqlite.database' => ':memory:']); + DB::purge('sqlite'); + (require database_path('migrations/2026_10_23_100000_create_content_security_checks.php'))->up(); + } + protected function tearDown(): void { DB::disconnect('sqlite'); parent::tearDown(); } + private function pending() { + return DB::table('content_security_checks')->insertGetId(['user_id' => 1, 'upload_id' => 7, 'trace_id' => 'trace-1', 'url' => 'https://example.test/avatar.jpg', 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]); + } + public function test_pending_reject_error_and_foreign_images_cannot_be_saved() { + $this->pending(); $service = new AvatarCheck; + foreach (['pending', 'reject', 'error', 'pass'] as $status) { + DB::table('content_security_checks')->update(['status' => $status]); + foreach ([1, 2] as $user) { + try { $service->assertApproved($user, 'https://example.test/avatar.jpg'); $allowed = true; } + catch (\InvalidArgumentException $e) { $allowed = false; } + $this->assertSame($status === 'pass' && $user === 1, $allowed); + } + } + $this->expectException(\InvalidArgumentException::class); + $service->assertApproved(1, 'https://example.test/arbitrary.jpg'); + } + public function test_only_successful_pass_callback_approves_and_duplicates_are_ignored() { + $id = $this->pending(); $service = new AvatarCheck; + $this->assertNull($service->result(1, $id)['url']); + $service->receive(['Event'=>'wxa_media_check','trace_id'=>'trace-1','errcode'=>0,'result'=>['suggest'=>'pass']]); + $this->assertSame('pass', $service->result(1, $id)['status']); + $service->receive(['Event'=>'wxa_media_check','trace_id'=>'trace-1','errcode'=>0,'result'=>['suggest'=>'risky']]); + $this->assertSame('pass', $service->result(1, $id)['status']); + $this->expectException(\InvalidArgumentException::class); $service->result(2, $id); + } + public function test_review_and_missing_results_are_not_pass() { + foreach ([['errcode'=>0,'result'=>['suggest'=>'review']], ['errcode'=>0], ['errcode'=>-1,'result'=>['suggest'=>'pass']]] as $data) { + DB::table('content_security_checks')->delete(); $id=$this->pending(); + (new AvatarCheck)->receive(array_merge(['Event'=>'wxa_media_check','trace_id'=>'trace-1'], $data)); + $this->assertNotSame('pass', (new AvatarCheck)->result(1, $id)['status']); + } + } + public function test_callback_rejects_unsigned_requests() { + config(['content-security.token'=>'test-token','content-security.aes_key'=>str_repeat('a',43)]); + $this->postJson('/api/wechat/content-security/callback', ['Event'=>'wxa_media_check'])->assertStatus(400); + } + public function test_signed_callback_updates_check_and_returns_success() { + $id=$this->pending(); + config(['content-security.token'=>'test-token','content-security.aes_key'=>str_repeat('a',43)]); + $timestamp=(string)time(); $nonce='test-nonce'; $parts=['test-token',$timestamp,$nonce]; sort($parts,SORT_STRING); + $url='/api/wechat/content-security/callback?timestamp='.$timestamp.'&nonce='.$nonce.'&signature='.sha1(implode('', $parts)); + $this->postJson($url, ['MsgType'=>'event','Event'=>'wxa_media_check','trace_id'=>'trace-1','errcode'=>0,'result'=>['suggest'=>'pass']])->assertOk(); + $this->assertSame('pass', (new AvatarCheck)->result(1,$id)['status']); + } + + public function test_encrypted_callback_is_decrypted_and_checked() { + $id=$this->pending(); $token='test-token'; $key=str_repeat('a',43); $appid='wx-test'; + config(['content-security.token'=>$token,'content-security.aes_key'=>$key,'app.applet_appid'=>$appid]); + $timestamp=time(); $nonce='encrypted-nonce'; $parts=[$token,(string)$timestamp,$nonce]; sort($parts,SORT_STRING); + $message=json_encode(['MsgType'=>'event','Event'=>'wxa_media_check','trace_id'=>'trace-1','errcode'=>0,'result'=>['suggest'=>'pass']]); + $encrypted=(new \EasyWeChat\MiniProgram\Encryptor($appid,$token,$key))->encrypt($message,$nonce,$timestamp); + $envelope=\EasyWeChat\Kernel\Support\XML::parse($encrypted); + $url='/api/wechat/content-security/callback?encrypt_type=aes×tamp='.$timestamp.'&nonce='.$nonce.'&signature='.sha1(implode('',$parts)).'&msg_signature='.$envelope['MsgSignature']; + $this->call('POST',$url,[],[],[],['CONTENT_TYPE'=>'text/xml'],$encrypted)->assertOk(); + $this->assertSame('pass',(new AvatarCheck)->result(1,$id)['status']); + } + public function test_update_endpoint_refuses_unapproved_avatar_without_changing_user() { + $this->withoutMiddleware(); + Schema::create('users',function(Blueprint $t){$t->id();$t->string('headimgurl')->nullable();$t->timestamps();$t->softDeletes();}); + DB::table('users')->insert(['id'=>1,'headimgurl'=>'old']); + \Illuminate\Support\Facades\Auth::guard('mobile')->setUser(\App\Models\User::find(1)); + $this->postJson('/api/mobile/user/update-user',['headimgurl'=>'https://example.test/unreviewed.jpg'])->assertOk()->assertJsonStructure(['errcode','errmsg']); + $this->assertSame('old',DB::table('users')->where('id',1)->value('headimgurl')); + \Illuminate\Support\Facades\Auth::guard('mobile')->forgetUser(); + } + + public function test_callback_url_verification_echoes_only_with_valid_signature() { + config(['content-security.token'=>'test-token','content-security.aes_key'=>str_repeat('a',43)]); + $timestamp=(string)time(); $nonce='verify'; $parts=['test-token',$timestamp,$nonce]; sort($parts,SORT_STRING); + $url='/api/wechat/content-security/callback?timestamp='.$timestamp.'&nonce='.$nonce.'&echostr=hello&signature='; + $this->get($url.'invalid')->assertStatus(400); + $this->get($url.sha1(implode('',$parts)))->assertOk()->assertSeeText('hello'); + } + +}