parent
a20be1201e
commit
9bd60445fd
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Mobile;
|
||||
use App\Helpers\ResponseCode;
|
||||
use App\Services\ContentSecurity\AvatarCheck;
|
||||
|
||||
class ContentSecurityController extends CommonController
|
||||
{
|
||||
public function avatarStatus(AvatarCheck $checks) {
|
||||
try { return $this->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'); }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
namespace App\Services\ContentSecurity;
|
||||
|
||||
use App\Models\Upload;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class AvatarCheck
|
||||
{
|
||||
public function submit($user, Upload $upload): array
|
||||
{
|
||||
if (!$user->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('头像尚未通过内容检测,请重新上传并等待检测通过');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
<?php
|
||||
return [
|
||||
'token' => env('WECHAT_CONTENT_SECURITY_TOKEN'),
|
||||
'aes_key' => env('WECHAT_CONTENT_SECURITY_AES_KEY'),
|
||||
];
|
||||
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration {
|
||||
public function up() { Schema::create('content_security_checks', function (Blueprint $t) {
|
||||
$t->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'); }
|
||||
};
|
||||
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
namespace Tests\Feature;
|
||||
use Tests\TestCase;
|
||||
use App\Services\ContentSecurity\AvatarCheck;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
|
||||
class AvatarContentSecurityTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
config(['database.default' => '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');
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in new issue