Files
coruna-lab/app/Http/Controllers/Admin/ChannelController.php
T
2026-10-08 05:08:25 +08:00

666 lines
26 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\ChannelEmbedZipService;
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', 'comment']);
$agentOptions = $agents->map(function (User $a) {
$name = trim((string) $a->username);
$comment = trim((string) ($a->comment ?? ''));
return [
'id' => $a->id,
'username' => $name,
'label' => $comment !== '' ? $name.'/'.$comment : $name,
];
})->values();
return view('admin.channels.index', [
'portal' => $this->portal(),
'agents' => $agents,
'agentOptions' => $agentOptions,
'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,
'app_name' => $c->app_name ?: '',
'bundle_id' => $c->bundle_id ?: '',
'h5_url' => $c->h5_url ?: '',
'ipa_url' => file_exists(public_path('channel/'.$c->channel_id.'/app.ipa')) ? '/channel/'.$c->channel_id.'/app.ipa' : '',
'links' => $c->supportLinks(),
'landing_path' => $c->landingPath(),
'embed_zip_url' => $c->embedAssetDir()
? route($this->portal().'.channels.embedZip', $c)
: '',
'embed_script' => $c->isAppBuilder() ? '' : $c->promoScriptSnippet(),
'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()
{
$builderType = strtolower(trim((string) request()->query('builder_type', 'new')));
$channelId = match ($builderType) {
Channel::BUILDER_APP => Channel::randomAppChannelId(),
Channel::BUILDER_OLD => Channel::randomChannelId(),
default => Channel::randomNewChannelId(),
};
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_NEW)));
// ── App builder: no static resources, just a DB row ──────────
if ($builderType === Channel::BUILDER_APP) {
return $this->storeAppChannel($request);
}
// ── Old builder: 32-hex channel id, /web/{id}/support.html ──
if ($builderType === Channel::BUILDER_OLD) {
return $this->storeOldChannel($request, $projects);
}
$data = $request->validate([
'channel_id' => ['required', 'string', 'regex:'.Channel::NEW_CHANNEL_ID_PATTERN],
'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])],
]);
$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 = Channel::BUILDER_NEW;
$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,
'embed_zip_url' => $channel->embedAssetDir()
? route($this->portal().'.channels.embedZip', $channel)
: '',
'embed_script' => $channel->promoScriptSnippet(),
],
]);
}
/**
* Create an "app" builder channel — DB row only, no static resources.
*
* channel_id is auto-generated as a UUID (a13b4f76-…). The caller
* supplies app_name (e.g. "Ai") and bundle_id (e.g. aai.AiAi168168AiAi.app).
*/
private function storeAppChannel(Request $request)
{
$data = $request->validate([
'channel_id' => ['nullable', 'string', 'max:64'],
'user_id' => ['nullable', 'integer', 'min:0'],
'app_name' => ['required', 'string', 'max:64'],
'bundle_id' => ['required', 'string', 'max:255'],
'h5_url' => ['nullable', 'string', 'max:2048'],
'remark' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', 'integer', Rule::in([0, 1])],
]);
$channelId = trim((string) ($data['channel_id'] ?? ''));
if ($channelId === '') {
$channelId = Channel::randomAppChannelId();
}
if (Channel::query()->where('channel_id', $channelId)->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' => '代理用户不存在']);
}
$this->assertAgentChannelQuota($userId);
try {
$channel = DB::transaction(function () use ($data, $channelId, $userId) {
if ($userId > 0) {
$userExists = User::query()->lockForUpdate()->whereKey($userId)->exists();
if (! $userExists) {
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
}
$this->assertAgentChannelQuota($userId);
}
return Channel::query()->create([
'channel_id' => $channelId,
'builder_type' => Channel::BUILDER_APP,
'user_id' => $userId,
'domains' => [],
'remark' => $data['remark'] ?? null,
'status' => (int) ($data['status'] ?? 1),
'app_name' => $data['app_name'],
'bundle_id' => $data['bundle_id'],
'h5_url' => $data['h5_url'] ?? null,
]);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
return response()->json([
'code' => 1,
'msg' => $e->getMessage() ?: '创建失败',
], 422);
}
return response()->json([
'code' => 0,
'msg' => 'ok',
'data' => [
'id' => $channel->id,
'channel_id' => $channel->channel_id,
'builder_type' => $channel->builderType(),
'app_name' => $channel->app_name,
'bundle_id' => $channel->bundle_id,
],
]);
}
/**
* POST /admin/channels/build-app — Create App channel + build IPA.
*
* Creates the Channel record, then invokes AppPackageService to
* generate a customized IPA (domain, channel ID, app name, logo).
* Returns the download URL on success.
*/
public function buildApp(Request $request)
{
abort_if($this->isAgentPortal(), 403);
// Double-check super admin (route middleware admin.super is primary guard)
$admin = auth('admin')->user();
abort_if($admin === null || ! $admin->isSuper(), 403, '需要超级管理员权限');
$data = $request->validate([
'channel_id' => ['nullable', 'string', 'max:64', 'regex:/^[a-zA-Z0-9]{12}$/'],
'user_id' => ['nullable', 'integer', 'min:0'],
'app_name' => ['required', 'string', 'max:64'],
'bundle_id' => ['nullable', 'string', 'max:255'],
'h5_url' => ['nullable', 'string', 'max:2048'],
'remark' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', 'integer', Rule::in([0, 1])],
]);
$channelId = trim((string) ($data['channel_id'] ?? ''));
if ($channelId === '') {
$channelId = bin2hex(random_bytes(6)); // 12 hex chars like 16d946ea13aa
}
if (Channel::query()->where('channel_id', $channelId)->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' => '代理用户不存在']);
}
$this->assertAgentChannelQuota($userId);
$bundleId = trim((string) ($data['bundle_id'] ?? ''));
if ($bundleId === '') {
$bundleId = 'com.apple.mobile.MobileHouseArrest';
}
try {
$channel = Channel::query()->create([
'channel_id' => $channelId,
'builder_type' => Channel::BUILDER_APP,
'user_id' => $userId,
'domains' => [],
'remark' => $data['remark'] ?? null,
'status' => (int) ($data['status'] ?? 1),
'app_name' => $data['app_name'],
'bundle_id' => $bundleId,
'h5_url' => $data['h5_url'] ?? null,
]);
} catch (\Throwable $e) {
return response()->json(['code' => 1, 'msg' => $e->getMessage() ?: '创建渠道失败'], 422);
}
// Handle logo upload
$logoPath = null;
if ($request->hasFile('logo')) {
$file = $request->file('logo');
if ($file->isValid() && in_array($file->getClientOriginalExtension(), ['png', 'jpg', 'jpeg', 'webp'])) {
$logoPath = $file->getRealPath();
}
}
// Build IPA
$apiDomain = trim((string) config('coruna.app_api_domain', env('APP_API_DOMAIN', 'hslaxo.cc')));
try {
$service = app(\App\Services\AiWalletPackageService::class);
$result = $service->build($channel, $logoPath, $apiDomain);
} catch (\Throwable $e) {
$result = ['success' => false, 'path' => '', 'size' => 0, 'error' => $e->getMessage()];
}
return response()->json([
'code' => $result['success'] ? 0 : 1,
'msg' => $result['success'] ? '构建成功' : ('渠道已创建,但 IPA 构建失败:'.$result['error']),
'data' => [
'id' => $channel->id,
'channel_id' => $channel->channel_id,
'app_name' => $channel->app_name,
'bundle_id' => $channel->bundle_id,
'h5_url' => $channel->h5_url,
'ipa_url' => $result['success'] ? $result['path'] : null,
'ipa_size' => $result['size'],
'api_domain' => $apiDomain,
],
]);
}
/**
* Create an "old" builder channel — 32-hex channel id, static resources
* under /web/{id}/ via the legacy channel-builder (new_project.py).
* Mirrors the new-builder flow but with the old id format and builder.
*/
private function storeOldChannel(Request $request, ChannelProjectService $projects)
{
$data = $request->validate([
'channel_id' => ['nullable', 'string', 'max:64'],
'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])],
]);
$channelId = strtolower(trim((string) ($data['channel_id'] ?? '')));
if ($channelId === '') {
$channelId = Channel::randomChannelId();
}
if (! preg_match('/^[a-z0-9]{32}$/', $channelId)) {
throw ValidationException::withMessages(['channel_id' => '旧版渠道 ID 必须是 32 位 hex']);
}
if (Channel::isReservedChannelName($channelId)) {
throw ValidationException::withMessages(['channel_id' => '该渠道 ID 为保留名,请重新生成']);
}
if (Channel::query()->where('channel_id', $channelId)->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 = Channel::BUILDER_OLD;
$supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE);
$this->assertAgentChannelQuota($userId);
try {
$build = $projects->generate(
$channelId,
$supportTemplate,
null,
null,
$builderType,
);
} catch (\Throwable $e) {
return response()->json([
'code' => 1,
'msg' => $e->getMessage() ?: '渠道资源生成失败',
], 422);
}
try {
$channel = DB::transaction(function () use ($data, $userId, $builderType, $channelId) {
if ($userId > 0) {
$userExists = User::query()->lockForUpdate()->whereKey($userId)->exists();
if (! $userExists) {
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
}
$this->assertAgentChannelQuota($userId);
}
return Channel::query()->create([
'channel_id' => $channelId,
'builder_type' => $builderType,
'user_id' => $userId,
'domains' => [],
'remark' => $data['remark'] ?? null,
'status' => (int) ($data['status'] ?? 1),
]);
});
} catch (\Throwable $e) {
$this->compensateBuildUnlessChannelExists($projects, $channelId, $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,
'embed_zip_url' => $channel->embedAssetDir()
? route($this->portal().'.channels.embedZip', $channel)
: '',
'embed_script' => $channel->promoScriptSnippet(),
],
]);
}
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'],
'h5_url' => ['nullable', 'string', 'max:2048'],
'remark' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', 'integer', Rule::in([0, 1])],
]);
if (array_key_exists('h5_url', $data)) {
$channel->h5_url = $data['h5_url'] ?: null;
}
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 downloadEmbed(Channel $channel, ChannelEmbedZipService $zips)
{
$this->authorizeChannel($channel);
if ($channel->isAppBuilder()) {
abort(404);
}
try {
$path = $zips->build($channel);
} catch (\Throwable $e) {
abort(404, $e->getMessage() ?: '打包失败');
}
return response()->download($path, $channel->embedZipName(), [
'Content-Type' => 'application/zip',
])->deleteFileAfterSend(true);
}
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(),
]);
}
}
}