339 lines
13 KiB
PHP
339 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Admin;
|
|
|
|
use App\Http\Controllers\Concerns\PortalAware;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Channel;
|
|
use App\Models\User;
|
|
use App\Services\ChannelProjectService;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class ChannelController extends Controller
|
|
{
|
|
use PortalAware;
|
|
|
|
public function index()
|
|
{
|
|
$agents = $this->isAgentPortal()
|
|
? collect()
|
|
: User::query()->orderBy('username')->get(['id', 'username']);
|
|
|
|
return view('admin.channels.index', [
|
|
'portal' => $this->portal(),
|
|
'agents' => $agents,
|
|
'maxPerAgent' => Channel::maxPerAgent(),
|
|
]);
|
|
}
|
|
|
|
public function data(Request $request)
|
|
{
|
|
$q = Channel::query()->with('user:id,username');
|
|
|
|
if ($agent = $this->agent()) {
|
|
$q->where('user_id', $agent->id);
|
|
}
|
|
|
|
$channelId = trim((string) $request->query('channel_id', ''));
|
|
$agentUserId = $request->query('agent_user_id');
|
|
$status = $request->query('status');
|
|
|
|
if ($channelId !== '') {
|
|
$q->where('channel_id', 'like', '%'.$channelId.'%');
|
|
}
|
|
if (! $this->isAgentPortal() && $agentUserId !== null && $agentUserId !== '') {
|
|
$q->where('user_id', (int) $agentUserId);
|
|
}
|
|
if ($status !== null && $status !== '') {
|
|
$q->where('status', (int) $status);
|
|
}
|
|
|
|
$sortable = ['id', 'channel_id', 'builder_type', 'status', 'user_id', 'created_at', 'updated_at'];
|
|
$field = (string) $request->query('field', 'id');
|
|
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
|
if (! in_array($field, $sortable, true)) {
|
|
$field = 'id';
|
|
}
|
|
$q->orderBy($field, $order);
|
|
|
|
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
|
$page = max(1, (int) $request->query('page', 1));
|
|
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
|
|
|
$data = collect($paginator->items())->map(function (Channel $c) {
|
|
return [
|
|
'id' => $c->id,
|
|
'channel_id' => $c->channel_id,
|
|
'builder_type' => $c->builderType(),
|
|
'user_id' => (int) $c->user_id,
|
|
'agent_username' => $c->agentLabel(),
|
|
'remark' => $c->remark ?: '',
|
|
'status' => (int) $c->status,
|
|
'links' => $c->supportLinks(),
|
|
'landing_path' => $c->landingPath(),
|
|
'created_at' => optional($c->created_at)->format('Y-m-d H:i:s'),
|
|
'updated_at' => optional($c->updated_at)->format('Y-m-d H:i:s'),
|
|
];
|
|
})->values();
|
|
|
|
return response()->json([
|
|
'code' => 0,
|
|
'msg' => '',
|
|
'count' => $paginator->total(),
|
|
'data' => $data,
|
|
]);
|
|
}
|
|
|
|
public function randomId(Request $request)
|
|
{
|
|
$builderType = strtolower(trim((string) $request->query('builder_type', Channel::BUILDER_OLD)));
|
|
$channelId = $builderType === Channel::BUILDER_NEW
|
|
? Channel::randomNewChannelId()
|
|
: Channel::randomChannelId();
|
|
|
|
return response()->json([
|
|
'code' => 0,
|
|
'msg' => '',
|
|
'data' => ['channel_id' => $channelId],
|
|
]);
|
|
}
|
|
|
|
public function store(Request $request, ChannelProjectService $projects)
|
|
{
|
|
abort_if($this->isAgentPortal(), 403);
|
|
|
|
$builderType = strtolower(trim((string) $request->input('builder_type', Channel::BUILDER_OLD)));
|
|
if (! in_array($builderType, [Channel::BUILDER_OLD, Channel::BUILDER_NEW], true)) {
|
|
$builderType = Channel::BUILDER_OLD;
|
|
}
|
|
|
|
$channelIdRule = $builderType === Channel::BUILDER_NEW
|
|
? ['required', 'string', 'regex:'.Channel::NEW_CHANNEL_ID_PATTERN]
|
|
: ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')];
|
|
|
|
$data = $request->validate([
|
|
'channel_id' => $channelIdRule,
|
|
'builder_type' => ['nullable', 'string', Rule::in([Channel::BUILDER_OLD, Channel::BUILDER_NEW])],
|
|
'user_id' => ['nullable', 'integer', 'min:0'],
|
|
'support_template' => ['nullable', 'string', Rule::in(ChannelProjectService::SUPPORT_TEMPLATES)],
|
|
'deployment_seed' => ['nullable', 'string', 'min:1', 'max:32'],
|
|
'reporting_seed' => ['nullable', 'string', 'min:1', 'max:32'],
|
|
'remark' => ['nullable', 'string', 'max:255'],
|
|
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
|
]);
|
|
|
|
if ($builderType === Channel::BUILDER_NEW) {
|
|
$normalized = Channel::normalizeNewChannelId((string) $data['channel_id']);
|
|
if ($normalized === null) {
|
|
throw ValidationException::withMessages(['channel_id' => '新版渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)']);
|
|
}
|
|
$data['channel_id'] = $normalized;
|
|
if (Channel::query()->where('channel_id', $normalized)->exists()) {
|
|
throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']);
|
|
}
|
|
}
|
|
|
|
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
|
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
|
|
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
|
}
|
|
|
|
$builderType = (string) ($data['builder_type'] ?? $builderType);
|
|
$supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE);
|
|
$this->assertAgentChannelQuota($userId);
|
|
|
|
try {
|
|
$build = $projects->generate(
|
|
$data['channel_id'],
|
|
$supportTemplate,
|
|
$data['deployment_seed'] ?? null,
|
|
$data['reporting_seed'] ?? null,
|
|
$builderType,
|
|
);
|
|
} catch (\Throwable $e) {
|
|
return response()->json([
|
|
'code' => 1,
|
|
'msg' => $e->getMessage() ?: '渠道资源生成失败',
|
|
], 422);
|
|
}
|
|
|
|
try {
|
|
$channel = DB::transaction(function () use ($data, $userId, $builderType) {
|
|
if ($userId > 0) {
|
|
$userExists = User::query()->lockForUpdate()->whereKey($userId)->exists();
|
|
if (! $userExists) {
|
|
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
|
}
|
|
// The user row lock serializes quota checks for this agent.
|
|
$this->assertAgentChannelQuota($userId);
|
|
}
|
|
|
|
return Channel::query()->create([
|
|
'channel_id' => $data['channel_id'],
|
|
'builder_type' => $builderType,
|
|
'user_id' => $userId,
|
|
'domains' => [],
|
|
'remark' => $data['remark'] ?? null,
|
|
'status' => (int) ($data['status'] ?? 1),
|
|
]);
|
|
});
|
|
} catch (\Throwable $e) {
|
|
$this->compensateBuildUnlessChannelExists($projects, $data['channel_id'], $builderType);
|
|
|
|
return response()->json([
|
|
'code' => 1,
|
|
'msg' => $e->getMessage() ?: '创建失败',
|
|
], 422);
|
|
}
|
|
|
|
return response()->json([
|
|
'code' => 0,
|
|
'msg' => 'ok',
|
|
'data' => [
|
|
'id' => $channel->id,
|
|
'builder_type' => $channel->builderType(),
|
|
'links' => $channel->supportLinks(),
|
|
'seeds' => $build['seeds'],
|
|
'domains' => $build['domains'],
|
|
'seeds_initialized' => $build['seeds_initialized'],
|
|
'sync_rebuilt' => $build['sync_rebuilt'],
|
|
'support_path' => $build['support_path'] ?? $channel->landingPath(),
|
|
'weifile_path' => $build['weifile_path'] ?? null,
|
|
'daily_path' => $build['daily_path'] ?? '',
|
|
'channel_dir' => $build['channel_dir'] ?? null,
|
|
'show_alias' => $build['show_alias'] ?? null,
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function update(Request $request, Channel $channel)
|
|
{
|
|
$this->authorizeChannel($channel);
|
|
|
|
if ($this->isAgentPortal()) {
|
|
$data = $request->validate([
|
|
'remark' => ['nullable', 'string', 'max:255'],
|
|
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
|
]);
|
|
if (array_key_exists('remark', $data)) {
|
|
$channel->remark = $data['remark'];
|
|
}
|
|
if (array_key_exists('status', $data) && $data['status'] !== null) {
|
|
$channel->status = (int) $data['status'];
|
|
}
|
|
} else {
|
|
$data = $request->validate([
|
|
'user_id' => ['nullable', 'integer', 'min:0'],
|
|
'remark' => ['nullable', 'string', 'max:255'],
|
|
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
|
]);
|
|
if (array_key_exists('user_id', $data)) {
|
|
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
|
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
|
|
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
|
}
|
|
if ($userId !== (int) $channel->user_id && $userId > 0) {
|
|
$this->assertAgentChannelQuota($userId, $channel->id);
|
|
}
|
|
$channel->user_id = $userId;
|
|
}
|
|
if (array_key_exists('remark', $data)) {
|
|
$channel->remark = $data['remark'];
|
|
}
|
|
if (array_key_exists('status', $data) && $data['status'] !== null) {
|
|
$channel->status = (int) $data['status'];
|
|
}
|
|
}
|
|
|
|
$channel->save();
|
|
|
|
return response()->json([
|
|
'code' => 0,
|
|
'msg' => 'ok',
|
|
'data' => ['links' => $channel->supportLinks()],
|
|
]);
|
|
}
|
|
|
|
public function destroy(Channel $channel, ChannelProjectService $projects)
|
|
{
|
|
abort_if($this->isAgentPortal(), 403);
|
|
$this->authorizeChannel($channel);
|
|
|
|
$channelId = $channel->channel_id;
|
|
$builderType = $channel->builderType();
|
|
try {
|
|
// Delete remotely first: a failed remote delete leaves the DB row available
|
|
// for a safe retry instead of orphaning an unreachable static project.
|
|
$projects->deleteWebTree($channelId, $builderType);
|
|
DB::transaction(static fn () => $channel->delete());
|
|
} catch (\Throwable $e) {
|
|
return response()->json([
|
|
'code' => 1,
|
|
'msg' => $e->getMessage() ?: '删除失败',
|
|
], 422);
|
|
}
|
|
|
|
return response()->json(['code' => 0, 'msg' => 'ok']);
|
|
}
|
|
|
|
private function assertAgentChannelQuota(int $userId, ?int $exceptChannelId = null): void
|
|
{
|
|
if ($userId <= 0) {
|
|
return;
|
|
}
|
|
$q = Channel::query()->where('user_id', $userId);
|
|
if ($exceptChannelId) {
|
|
$q->where('id', '!=', $exceptChannelId);
|
|
}
|
|
$count = $q->count();
|
|
$max = Channel::maxPerAgent();
|
|
if ($count >= $max) {
|
|
throw ValidationException::withMessages([
|
|
'user_id' => "该代理最多只能拥有 {$max} 条渠道链接",
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function authorizeChannel(Channel $channel): void
|
|
{
|
|
if ($agent = $this->agent()) {
|
|
abort_unless((int) $channel->user_id === (int) $agent->id, 403);
|
|
}
|
|
}
|
|
|
|
private function compensateBuildUnlessChannelExists(
|
|
ChannelProjectService $projects,
|
|
string $channelId,
|
|
string $builderType = Channel::BUILDER_OLD,
|
|
): void {
|
|
try {
|
|
// A concurrent request may have won the unique channel_id insert. Its
|
|
// project must not be removed by this request's compensation.
|
|
if (Channel::query()->where('channel_id', $channelId)->exists()) {
|
|
return;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::warning('Skipped channel build compensation because DB state is unknown', [
|
|
'channel_id' => $channelId,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$projects->deleteWebTree($channelId, $builderType);
|
|
} catch (\Throwable $e) {
|
|
Log::error('Failed to compensate channel build', [
|
|
'channel_id' => $channelId,
|
|
'builder_type' => $builderType,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
}
|