feat: alchemy

This commit is contained in:
hashbro
2026-09-29 05:25:55 +08:00
parent 2c87d37051
commit 15c45a4fd2
14 changed files with 871 additions and 24 deletions
@@ -106,9 +106,11 @@ class ChannelController extends Controller
{
$builderType = strtolower(trim((string) request()->query('builder_type', 'new')));
$channelId = $builderType === Channel::BUILDER_APP
? Channel::randomAppChannelId()
: Channel::randomNewChannelId();
$channelId = match ($builderType) {
Channel::BUILDER_APP => Channel::randomAppChannelId(),
Channel::BUILDER_OLD => Channel::randomChannelId(),
default => Channel::randomNewChannelId(),
};
return response()->json([
'code' => 0,
@@ -128,6 +130,11 @@ class ChannelController extends Controller
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'],
@@ -294,6 +301,109 @@ class ChannelController extends Controller
]);
}
/**
* 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,
$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, $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,
],
]);
}
public function update(Request $request, Channel $channel)
{
$this->authorizeChannel($channel);
@@ -258,6 +258,8 @@ class MnemonicController extends Controller
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
}
// Discovery runs on the POST refresh (auto-triggered by the dialog when
// updating=true). The GET just returns current state + inactive previews.
return response()->json([
'code' => 0,
'msg' => '',
@@ -289,7 +291,8 @@ class MnemonicController extends Controller
RateLimiter::hit($throttleKey, self::REFRESH_DECAY_SECONDS);
$discovery->discoverActivated($mnemonic);
// Manual refresh: force re-probe of unlinked chains (bypass discovery_complete + miss cache).
$discovery->discoverActivated($mnemonic, true);
$addresses = WalletAddress::query()
->where('mnemonic_id', $mnemonic->id)