Ver3.5 라라벨(Laravel) 기반 DAO 제안서 관리 어드민 페이지 완전 구축 가이드

라라벨(Laravel) 기반 DAO 제안서 관리 어드민 페이지 완전 구축 가이드
탈중앙화 거버넌스를 중앙에서 관리하는 역설적이고 매력적인 시스템 만들기 🚀
야, 솔직히 말하자면 DAO(Decentralized Autonomous Organization)가 "탈중앙화"를 표방하는데 어드민 페이지를 만든다는 게 좀 아이러니하게 느껴질 수 있어. 😅
근데 현실을 직시해보자. 아무리 탈중앙화 거버넌스라도 운영팀이 제안서 현황을 한눈에 파악하고, 스팸 제안을 필터링하고, 투표 결과를 모니터링하고, 실행 상태를 추적해야 하는 건 피할 수 없는 현실이야.
실제로 Compound, Uniswap, MakerDAO 같은 메이저 DAO들도 내부적으로 제안서 관리 도구를 운영하고 있어. 온체인 데이터는 블록체인에 있지만, 그걸 사람이 읽기 좋게 정리하고 관리하는 인터페이스는 별도로 필요하거든.
라라벨(Laravel)을 선택한 이유도 명확해. PHP 생태계에서 가장 성숙한 풀스택 프레임워크이고, Eloquent ORM, Blade 템플릿, Queue 시스템, 이벤트 브로드캐스팅 등 어드민 시스템에 필요한 모든 도구가 이미 내장되어 있거든. 거기다 Web3 연동을 위한 PHP 라이브러리들도 꽤 잘 갖춰져 있어서 블록체인 데이터를 읽어오는 것도 어렵지 않아.
본격적으로 코드 짜기 전에 전체 그림을 먼저 그려보자. 아키텍처 설계 없이 코딩부터 시작하면 나중에 리팩토링 지옥을 경험하게 될 거야. 😤
위 아키텍처를 보면 크게 5개 레이어로 나뉘어. 각 레이어가 명확한 역할을 가지고 있어서 나중에 유지보수할 때 훨씬 편해. 특히 도메인 레이어를 독립적으로 설계하면 블록체인 연동 방식이 바뀌어도 비즈니스 로직은 그대로 유지할 수 있어.
DAO 어드민 시스템에 최적화된 디렉토리 구조를 잡아보자. 기본 라라벨 구조에서 약간 확장하는 방식이야.
dao-admin/
├── app/
│ ├── Console/
│ │ └── Commands/
│ │ ├── SyncProposalsFromChain.php # 온체인 제안 동기화
│ │ └── UpdateVoteResults.php # 투표 결과 업데이트
│ ├── Http/
│ │ ├── Controllers/
│ │ │ └── Admin/
│ │ │ ├── DashboardController.php
│ │ │ ├── ProposalController.php
│ │ │ ├── VoteController.php
│ │ │ ├── MemberController.php
│ │ │ └── ExecutionController.php
│ │ ├── Middleware/
│ │ │ ├── AdminAuth.php
│ │ │ └── Web3Verify.php # 지갑 서명 검증
│ │ └── Requests/
│ │ ├── CreateProposalRequest.php
│ │ └── UpdateProposalRequest.php
│ ├── Models/
│ │ ├── Proposal.php
│ │ ├── Vote.php
│ │ ├── DaoMember.php
│ │ ├── Execution.php
│ │ └── ProposalComment.php
│ ├── Services/
│ │ ├── BlockchainService.php # Web3 연동 핵심
│ │ ├── ProposalService.php
│ │ ├── VoteService.php
│ │ └── IpfsService.php
│ ├── Jobs/
│ │ ├── SyncProposalJob.php
│ │ └── SendProposalNotification.php
│ └── Policies/
│ └── ProposalPolicy.php
├── database/
│ ├── migrations/
│ └── seeders/
├── resources/
│ └── views/
│ └── admin/
│ ├── layouts/
│ ├── dashboard/
│ ├── proposals/
│ ├── votes/
│ └── members/
└── routes/
├── web.php
└── api.php
DAO 시스템의 핵심 데이터 구조를 설계해보자. 온체인 데이터와 오프체인 데이터를 어떻게 연결할지가 포인트야.
제안서 테이블은 DAO 시스템의 심장이야. 온체인 ID와 오프체인 메타데이터를 함께 관리해야 해.
// database/migrations/create_proposals_table.php
public function up(): void
{
Schema::create('proposals', function (Blueprint $table) {
$table->id();
// 온체인 연동 필드
$table->string('chain_proposal_id')->nullable()->unique(); // 스마트컨트랙트 ID
$table->string('tx_hash')->nullable(); // 생성 트랜잭션 해시
$table->string('contract_address', 42); // 거버넌스 컨트랙트 주소
$table->string('proposer_address', 42); // 제안자 지갑 주소
$table->string('ipfs_hash')->nullable(); // IPFS 상세 내용 해시
// 제안서 기본 정보
$table->string('title');
$table->text('summary'); // 요약 (오프체인 저장)
$table->longText('description')->nullable(); // 상세 내용
$table->string('category'); // 카테고리 (treasury/protocol/community)
// 상태 관리
$table->enum('status', [
'draft', // 초안
'pending', // 검토 대기
'active', // 투표 진행중
'succeeded', // 투표 통과
'defeated', // 투표 부결
'queued', // 실행 대기
'executed', // 실행 완료
'cancelled', // 취소됨
'expired' // 만료됨
])->default('draft');
// 투표 기간
$table->timestamp('voting_start_at')->nullable();
$table->timestamp('voting_end_at')->nullable();
$table->bigInteger('start_block')->nullable();
$table->bigInteger('end_block')->nullable();
// 투표 결과 (캐시용 - 온체인에서 주기적으로 동기화)
$table->decimal('votes_for', 30, 18)->default(0);
$table->decimal('votes_against', 30, 18)->default(0);
$table->decimal('votes_abstain', 30, 18)->default(0);
$table->integer('voters_count')->default(0);
$table->decimal('quorum_required', 30, 18)->default(0);
$table->boolean('quorum_reached')->default(false);
// 실행 관련
$table->string('execution_tx_hash')->nullable();
$table->timestamp('executed_at')->nullable();
$table->json('execution_calls')->nullable(); // 실행할 컨트랙트 콜 목록
// 어드민 관리 필드
$table->foreignId('reviewed_by')->nullable()->constrained('users');
$table->timestamp('reviewed_at')->nullable();
$table->text('admin_note')->nullable();
$table->boolean('is_featured')->default(false);
$table->integer('priority')->default(0);
$table->timestamps();
$table->softDeletes();
// 인덱스
$table->index(['status', 'voting_end_at']);
$table->index('proposer_address');
$table->index('chain_proposal_id');
});
}
// database/migrations/create_votes_table.php
public function up(): void
{
Schema::create('votes', function (Blueprint $table) {
$table->id();
$table->foreignId('proposal_id')->constrained()->cascadeOnDelete();
// 투표자 정보
$table->string('voter_address', 42); // 지갑 주소
$table->string('voter_ens')->nullable(); // ENS 이름 (있을 경우)
$table->foreignId('dao_member_id')->nullable()->constrained('dao_members');
// 투표 내용
$table->enum('support', ['for', 'against', 'abstain']);
$table->decimal('voting_power', 30, 18); // 투표 가중치 (토큰 수)
$table->text('reason')->nullable(); // 투표 이유
// 온체인 검증
$table->string('tx_hash')->nullable();
$table->bigInteger('block_number')->nullable();
$table->timestamp('voted_at');
$table->boolean('is_verified')->default(false); // 온체인 검증 여부
$table->timestamps();
// 중복 투표 방지
$table->unique(['proposal_id', 'voter_address']);
$table->index(['proposal_id', 'support']);
$table->index('voter_address');
});
}
// database/migrations/create_dao_members_table.php
public function up(): void
{
Schema::create('dao_members', function (Blueprint $table) {
$table->id();
$table->string('wallet_address', 42)->unique();
$table->string('ens_name')->nullable();
$table->string('display_name')->nullable();
$table->string('avatar_url')->nullable();
$table->text('bio')->nullable();
// 토큰/거버넌스 파워
$table->decimal('token_balance', 30, 18)->default(0);
$table->decimal('delegated_power', 30, 18)->default(0);
$table->string('delegate_address', 42)->nullable();
// 활동 통계
$table->integer('proposals_created')->default(0);
$table->integer('votes_cast')->default(0);
$table->decimal('participation_rate', 5, 2)->default(0);
// 역할 및 상태
$table->enum('role', ['member', 'delegate', 'guardian', 'admin'])->default('member');
$table->boolean('is_active')->default(true);
$table->boolean('is_verified')->default(false);
$table->timestamp('joined_at')->nullable();
$table->timestamp('last_active_at')->nullable();
// 어드민 관리
$table->boolean('is_banned')->default(false);
$table->text('ban_reason')->nullable();
$table->timestamp('token_snapshot_at')->nullable();
$table->timestamps();
$table->index(['token_balance', 'is_active']);
});
}
서비스 레이어는 비즈니스 로직의 핵심이야. 특히 BlockchainService는 Web3 연동의 핵심이니까 꼼꼼하게 설계해야 해.
PHP에서 이더리움 블록체인과 통신하려면 web3p/web3.php 라이브러리를 사용해. Composer로 설치하면 돼.
// composer.json에 추가
// "web3p/web3.php": "^0.1.4",
// "kornrunner/keccak": "^1.1"
// app/Services/BlockchainService.php
namespace App\Services;
use Web3\Web3;
use Web3\Contract;
use Web3\Utils;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class BlockchainService
{
private Web3 $web3;
private string $rpcUrl;
private string $governanceAddress;
private array $governanceAbi;
public function __construct()
{
$this->rpcUrl = config('dao.rpc_url');
$this->governanceAddress = config('dao.governance_contract');
$this->governanceAbi = json_decode(
file_get_contents(storage_path('abi/governance.json')),
true
);
$this->web3 = new Web3($this->rpcUrl);
}
/**
* 온체인에서 제안서 정보 가져오기
*/
public function getProposal(string $proposalId): array
{
$cacheKey = "proposal_chain_{$proposalId}";
return Cache::remember($cacheKey, 60, function () use ($proposalId) {
$contract = new Contract($this->web3->provider, $this->governanceAbi);
$result = [];
$contract->at($this->governanceAddress)->call(
'proposals',
$proposalId,
function ($err, $data) use (&$result) {
if ($err) {
Log::error('블록체인 제안서 조회 실패', [
'error' => $err->getMessage()
]);
throw new \Exception($err->getMessage());
}
$result = $this->formatProposalData($data);
}
);
return $result;
});
}
/**
* 제안서 상태 조회 (0:Pending, 1:Active, 2:Canceled, 3:Defeated, 4:Succeeded, 5:Queued, 6:Expired, 7:Executed)
*/
public function getProposalState(string $proposalId): int
{
$contract = new Contract($this->web3->provider, $this->governanceAbi);
$state = 0;
$contract->at($this->governanceAddress)->call(
'state',
$proposalId,
function ($err, $data) use (&$state) {
if (!$err) {
$state = (int) $data[0]->toString();
}
}
);
return $state;
}
/**
* 투표 현황 조회
*/
public function getProposalVotes(string $proposalId): array
{
$cacheKey = "votes_chain_{$proposalId}";
return Cache::remember($cacheKey, 30, function () use ($proposalId) {
$contract = new Contract($this->web3->provider, $this->governanceAbi);
$votes = [];
$contract->at($this->governanceAddress)->call(
'proposalVotes',
$proposalId,
function ($err, $data) use (&$votes) {
if (!$err) {
$votes = [
'against' => Utils::fromWei($data['againstVotes']->toString(), 'ether'),
'for' => Utils::fromWei($data['forVotes']->toString(), 'ether'),
'abstain' => Utils::fromWei($data['abstainVotes']->toString(), 'ether'),
];
}
}
);
return $votes;
});
}
/**
* 특정 주소의 투표 파워 조회
*/
public function getVotingPower(string $address, ?int $blockNumber = null): string
{
$contract = new Contract($this->web3->provider, $this->governanceAbi);
$power = '0';
$method = $blockNumber ? 'getPastVotes' : 'getVotes';
$params = $blockNumber ? [$address, $blockNumber] : [$address];
$contract->at($this->governanceAddress)->call(
$method,
...$params,
function ($err, $data) use (&$power) {
if (!$err) {
$power = Utils::fromWei($data[0]->toString(), 'ether');
}
}
);
return $power;
}
/**
* 현재 블록 번호 조회
*/
public function getCurrentBlock(): int
{
$blockNumber = 0;
$this->web3->eth->blockNumber(function ($err, $block) use (&$blockNumber) {
if (!$err) {
$blockNumber = hexdec($block->toString());
}
});
return $blockNumber;
}
/**
* 온체인 상태를 DB 상태로 매핑
*/
public function mapChainStateToDbStatus(int $chainState): string
{
return match($chainState) {
0 => 'pending',
1 => 'active',
2 => 'cancelled',
3 => 'defeated',
4 => 'succeeded',
5 => 'queued',
6 => 'expired',
7 => 'executed',
default => 'pending'
};
}
private function formatProposalData(array $data): array
{
return [
'proposer' => $data['proposer'] ?? '',
'eta' => isset($data['eta']) ? (int) $data['eta']->toString() : 0,
'start_block' => isset($data['startBlock']) ? (int) $data['startBlock']->toString() : 0,
'end_block' => isset($data['endBlock']) ? (int) $data['endBlock']->toString() : 0,
'for_votes' => isset($data['forVotes']) ? Utils::fromWei($data['forVotes']->toString(), 'ether') : '0',
'against_votes' => isset($data['againstVotes']) ? Utils::fromWei($data['againstVotes']->toString(), 'ether') : '0',
'canceled' => $data['canceled'] ?? false,
'executed' => $data['executed'] ?? false,
];
}
}
// app/Services/ProposalService.php
namespace App\Services;
use App\Models\Proposal;
use App\Models\DaoMember;
use App\Jobs\SyncProposalJob;
use App\Jobs\SendProposalNotification;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ProposalService
{
public function __construct(
private BlockchainService $blockchainService,
private IpfsService $ipfsService
) {}
/**
* 제안서 목록 조회 (필터링 + 페이지네이션)
*/
public function getProposals(array $filters = [], int $perPage = 20): LengthAwarePaginator
{
$query = Proposal::with(['reviewer', 'comments'])
->withCount('votes');
// 상태 필터
if (!empty($filters['status'])) {
$query->where('status', $filters['status']);
}
// 카테고리 필터
if (!empty($filters['category'])) {
$query->where('category', $filters['category']);
}
// 제안자 주소 검색
if (!empty($filters['proposer'])) {
$query->where('proposer_address', 'like', '%' . $filters['proposer'] . '%');
}
// 키워드 검색
if (!empty($filters['search'])) {
$query->where(function ($q) use ($filters) {
$q->where('title', 'like', '%' . $filters['search'] . '%')
->orWhere('summary', 'like', '%' . $filters['search'] . '%');
});
}
// 날짜 범위 필터
if (!empty($filters['date_from'])) {
$query->where('created_at', '>=', $filters['date_from']);
}
if (!empty($filters['date_to'])) {
$query->where('created_at', '<=', $filters['date_to']);
}
// 정렬
$sortBy = $filters['sort_by'] ?? 'created_at';
$sortDir = $filters['sort_dir'] ?? 'desc';
$query->orderBy($sortBy, $sortDir);
return $query->paginate($perPage);
}
/**
* 제안서 생성 (오프체인 초안)
*/
public function createDraft(array $data, DaoMember $member): Proposal
{
return DB::transaction(function () use ($data, $member) {
// IPFS에 상세 내용 업로드
$ipfsHash = null;
if (!empty($data['description'])) {
$ipfsHash = $this->ipfsService->uploadJson([
'title' => $data['title'],
'description' => $data['description'],
'author' => $member->wallet_address,
'created_at' => now()->toIso8601String(),
]);
}
$proposal = Proposal::create([
'title' => $data['title'],
'summary' => $data['summary'],
'description' => $data['description'],
'category' => $data['category'],
'proposer_address' => $member->wallet_address,
'ipfs_hash' => $ipfsHash,
'contract_address' => config('dao.governance_contract'),
'execution_calls' => $data['execution_calls'] ?? null,
'status' => 'draft',
]);
// 멤버 통계 업데이트
$member->increment('proposals_created');
Log::info('제안서 초안 생성', [
'proposal_id' => $proposal->id,
'proposer' => $member->wallet_address,
]);
return $proposal;
});
}
/**
* 온체인 제안서 동기화
*/
public function syncFromChain(string $chainProposalId): Proposal
{
$chainData = $this->blockchainService->getProposal($chainProposalId);
$chainState = $this->blockchainService->getProposalState($chainProposalId);
$chainVotes = $this->blockchainService->getProposalVotes($chainProposalId);
$proposal = Proposal::where('chain_proposal_id', $chainProposalId)->first();
$updateData = [
'status' => $this->blockchainService->mapChainStateToDbStatus($chainState),
'votes_for' => $chainVotes['for'] ?? 0,
'votes_against' => $chainVotes['against'] ?? 0,
'votes_abstain' => $chainVotes['abstain'] ?? 0,
'start_block' => $chainData['start_block'],
'end_block' => $chainData['end_block'],
];
if ($proposal) {
$proposal->update($updateData);
} else {
// 새로운 온체인 제안서 발견 시 자동 생성
$proposal = Proposal::create(array_merge($updateData, [
'chain_proposal_id' => $chainProposalId,
'proposer_address' => $chainData['proposer'],
'contract_address' => config('dao.governance_contract'),
'title' => "온체인 제안 #{$chainProposalId}",
'summary' => 'IPFS에서 내용을 불러오는 중...',
]));
// IPFS에서 내용 비동기 로드
SyncProposalJob::dispatch($proposal->id);
}
return $proposal;
}
/**
* 제안서 상태 수동 업데이트 (어드민 전용)
*/
public function updateStatus(Proposal $proposal, string $newStatus, ?string $adminNote = null): bool
{
$oldStatus = $proposal->status;
$proposal->update([
'status' => $newStatus,
'admin_note' => $adminNote,
]);
// 상태 변경 알림 발송
SendProposalNotification::dispatch($proposal, 'status_changed', [
'old_status' => $oldStatus,
'new_status' => $newStatus,
]);
Log::info('제안서 상태 변경', [
'proposal_id' => $proposal->id,
'old_status' => $oldStatus,
'new_status' => $newStatus,
]);
return true;
}
/**
* 대시보드 통계 데이터
*/
public function getDashboardStats(): array
{
return cache()->remember('dao_dashboard_stats', 300, function () {
return [
'total_proposals' => Proposal::count(),
'active_proposals' => Proposal::where('status', 'active')->count(),
'passed_proposals' => Proposal::whereIn('status', ['succeeded', 'executed'])->count(),
'defeated_proposals' => Proposal::where('status', 'defeated')->count(),
'total_votes' => DB::table('votes')->count(),
'unique_voters' => DB::table('votes')->distinct('voter_address')->count(),
'total_members' => DaoMember::where('is_active', true)->count(),
'avg_participation' => DaoMember::avg('participation_rate'),
'recent_proposals' => Proposal::latest()->take(5)->get(),
'status_distribution' => Proposal::selectRaw('status, count(*) as count')
->groupBy('status')
->pluck('count', 'status'),
];
});
}
}
// routes/web.php
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\ProposalController;
use App\Http\Controllers\Admin\VoteController;
use App\Http\Controllers\Admin\MemberController;
use App\Http\Controllers\Admin\ExecutionController;
// 어드민 라우트 그룹
Route::prefix('admin')
->name('admin.')
->middleware(['auth', 'admin.auth', 'verified'])
->group(function () {
// 대시보드
Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
Route::get('/stats/realtime', [DashboardController::class, 'realtimeStats'])->name('stats.realtime');
// 제안서 관리
Route::resource('proposals', ProposalController::class);
Route::patch('proposals/{proposal}/status', [ProposalController::class, 'updateStatus'])
->name('proposals.status');
Route::post('proposals/{proposal}/sync', [ProposalController::class, 'syncFromChain'])
->name('proposals.sync');
Route::post('proposals/sync-all', [ProposalController::class, 'syncAll'])
->name('proposals.sync-all');
Route::patch('proposals/{proposal}/feature', [ProposalController::class, 'toggleFeature'])
->name('proposals.feature');
// 투표 관리
Route::get('votes', [VoteController::class, 'index'])->name('votes.index');
Route::get('votes/{proposal}', [VoteController::class, 'show'])->name('votes.show');
Route::post('votes/{proposal}/sync', [VoteController::class, 'syncVotes'])->name('votes.sync');
Route::delete('votes/{vote}', [VoteController::class, 'destroy'])->name('votes.destroy');
// 멤버 관리
Route::resource('members', MemberController::class)->except(['create', 'store']);
Route::patch('members/{member}/ban', [MemberController::class, 'ban'])->name('members.ban');
Route::patch('members/{member}/unban', [MemberController::class, 'unban'])->name('members.unban');
Route::patch('members/{member}/role', [MemberController::class, 'updateRole'])->name('members.role');
Route::post('members/sync-balances', [MemberController::class, 'syncBalances'])
->name('members.sync-balances');
// 실행 관리
Route::get('executions', [ExecutionController::class, 'index'])->name('executions.index');
Route::post('executions/{proposal}/queue', [ExecutionController::class, 'queue'])
->name('executions.queue');
Route::post('executions/{proposal}/execute', [ExecutionController::class, 'execute'])
->name('executions.execute');
});
// API 라우트 (외부 연동용)
Route::prefix('api/v1')
->name('api.')
->middleware(['api', 'auth:sanctum'])
->group(function () {
Route::get('proposals', [ProposalController::class, 'apiIndex']);
Route::get('proposals/{proposal}', [ProposalController::class, 'apiShow']);
Route::get('proposals/{proposal}/votes', [VoteController::class, 'apiVotes']);
Route::get('stats', [DashboardController::class, 'apiStats']);
});
// app/Http/Controllers/Admin/ProposalController.php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\CreateProposalRequest;
use App\Http\Requests\UpdateProposalRequest;
use App\Models\Proposal;
use App\Services\ProposalService;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class ProposalController extends Controller
{
public function __construct(
private ProposalService $proposalService
) {
// 정책 기반 권한 체크
$this->authorizeResource(Proposal::class, 'proposal');
}
/**
* 제안서 목록
*/
public function index(Request $request): View
{
$filters = $request->only([
'status', 'category', 'proposer',
'search', 'date_from', 'date_to',
'sort_by', 'sort_dir'
]);
$proposals = $this->proposalService->getProposals($filters, 20);
$statusCounts = Proposal::selectRaw('status, count(*) as count')
->groupBy('status')
->pluck('count', 'status');
return view('admin.proposals.index', compact('proposals', 'filters', 'statusCounts'));
}
/**
* 제안서 상세
*/
public function show(Proposal $proposal): View
{
$proposal->load(['votes', 'reviewer', 'comments.author']);
// 온체인 실시간 데이터 (캐시 활용)
$chainData = null;
if ($proposal->chain_proposal_id) {
try {
$chainData = app(BlockchainService::class)
->getProposal($proposal->chain_proposal_id);
} catch (\Exception $e) {
// RPC 오류 시 DB 데이터로 폴백
}
}
return view('admin.proposals.show', compact('proposal', 'chainData'));
}
/**
* 제안서 상태 업데이트
*/
public function updateStatus(Request $request, Proposal $proposal): RedirectResponse
{
$this->authorize('updateStatus', $proposal);
$request->validate([
'status' => 'required|in:draft,pending,active,succeeded,defeated,queued,executed,cancelled,expired',
'admin_note' => 'nullable|string|max:1000',
]);
$this->proposalService->updateStatus(
$proposal,
$request->status,
$request->admin_note
);
return redirect()
->route('admin.proposals.show', $proposal)
->with('success', "제안서 상태가 '{$request->status}'로 변경되었습니다.");
}
/**
* 온체인 데이터 동기화
*/
public function syncFromChain(Proposal $proposal): RedirectResponse
{
$this->authorize('sync', $proposal);
if (!$proposal->chain_proposal_id) {
return back()->with('error', '온체인 제안서 ID가 없습니다.');
}
try {
$this->proposalService->syncFromChain($proposal->chain_proposal_id);
return back()->with('success', '온체인 데이터 동기화 완료!');
} catch (\Exception $e) {
return back()->with('error', '동기화 실패: ' . $e->getMessage());
}
}
/**
* 주요 제안서 토글
*/
public function toggleFeature(Proposal $proposal): RedirectResponse
{
$proposal->update(['is_featured' => !$proposal->is_featured]);
$msg = $proposal->is_featured ? '주요 제안서로 설정되었습니다.' : '주요 제안서 해제되었습니다.';
return back()->with('success', $msg);
}
}
어드민 UI는 Blade 템플릿 + Livewire + Alpine.js 조합으로 만들어. 이 조합이 라라벨 생태계에서 가장 자연스럽고 생산성이 높아.
{{-- resources/views/admin/layouts/app.blade.php --}}
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title', 'DAO 어드민') - {{ config('app.name') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>
<body class="bg-gray-50 font-sans">
{{-- 사이드바 --}}
<div class="flex h-screen overflow-hidden">
<aside class="w-64 bg-white shadow-sm flex flex-col">
{{-- 로고 --}}
<div class="p-6 border-b">
<h1 class="text-xl font-bold text-indigo-600">⛓️ DAO Admin</h1>
<p class="text-xs text-gray-400 mt-1">거버넌스 관리 시스템</p>
</div>
{{-- 네비게이션 --}}
<nav class="flex-1 p-4 space-y-1">
<a href="{{ route('admin.dashboard') }}"
class="nav-item {{ request()->routeIs('admin.dashboard') ? 'active' : '' }}">
📊 대시보드
</a>
<a href="{{ route('admin.proposals.index') }}"
class="nav-item {{ request()->routeIs('admin.proposals.*') ? 'active' : '' }}">
📋 제안서 관리
@if($pendingCount = \App\Models\Proposal::where('status','pending')->count())
<span class="badge">{{ $pendingCount }}</span>
@endif
</a>
<a href="{{ route('admin.votes.index') }}"
class="nav-item {{ request()->routeIs('admin.votes.*') ? 'active' : '' }}">
🗳️ 투표 관리
</a>
<a href="{{ route('admin.members.index') }}"
class="nav-item {{ request()->routeIs('admin.members.*') ? 'active' : '' }}">
👥 멤버 관리
</a>
<a href="{{ route('admin.executions.index') }}"
class="nav-item {{ request()->routeIs('admin.executions.*') ? 'active' : '' }}">
⚡ 실행 관리
</a>
</nav>
{{-- 하단 사용자 정보 --}}
<div class="p-4 border-t">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center text-sm">
{{ substr(auth()->user()->name, 0, 1) }}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ auth()->user()->name }}</p>
<p class="text-xs text-gray-400">{{ auth()->user()->role }}</p>
</div>
<form action="{{ route('logout') }}" method="POST">
@csrf
<button type="submit" class="text-gray-400 hover:text-red-500 text-xs">로그아웃</button>
</form>
</div>
</div>
</aside>
{{-- 메인 콘텐츠 --}}
<main class="flex-1 overflow-y-auto">
{{-- 상단 헤더 --}}
<header class="bg-white shadow-sm px-6 py-4 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-800">@yield('page-title')</h2>
<p class="text-sm text-gray-400">@yield('page-subtitle')</p>
</div>
<div class="flex items-center gap-3">
{{-- 실시간 블록 번호 --}}
<div class="text-xs text-gray-400"
x-data="{ block: 0 }"
x-init="setInterval(() => fetch('/api/current-block').then(r=>r.json()).then(d=>block=d.block), 15000)">
블록 #<span x-text="block">...</span>
</div>
@yield('header-actions')
</div>
</header>
{{-- 알림 메시지 --}}
@if(session('success'))
<div class="mx-6 mt-4 p-3 bg-green-50 border border-green-200 rounded-lg text-green-700 text-sm">
✅ {{ session('success') }}
</div>
@endif
@if(session('error'))
<div class="mx-6 mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
❌ {{ session('error') }}
</div>
@endif
{{-- 페이지 콘텐츠 --}}
<div class="p-6">
@yield('content')
</div>
</main>
</div>
@livewireScripts
</body>
</html>
{{-- resources/views/admin/dashboard/index.blade.php --}}
@extends('admin.layouts.app')
@section('page-title', '대시보드')
@section('page-subtitle', '거버넌스 현황 한눈에 보기')
@section('content')
{{-- KPI 카드 그리드 --}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-100">
<div class="text-2xl mb-1">📋</div>
<div class="text-2xl font-bold text-gray-800">{{ $stats['total_proposals'] }}</div>
<div class="text-sm text-gray-400">전체 제안서</div>
</div>
<div class="bg-white rounded-xl p-5 shadow-sm border border-indigo-100">
<div class="text-2xl mb-1">🔥</div>
<div class="text-2xl font-bold text-indigo-600">{{ $stats['active_proposals'] }}</div>
<div class="text-sm text-gray-400">투표 진행중</div>
</div>
<div class="bg-white rounded-xl p-5 shadow-sm border border-green-100">
<div class="text-2xl mb-1">✅</div>
<div class="text-2xl font-bold text-green-600">{{ $stats['passed_proposals'] }}</div>
<div class="text-sm text-gray-400">통과된 제안</div>
</div>
<div class="bg-white rounded-xl p-5 shadow-sm border border-gray-100">
<div class="text-2xl mb-1">👥</div>
<div class="text-2xl font-bold text-gray-800">{{ $stats['total_members'] }}</div>
<div class="text-sm text-gray-400">활성 멤버</div>
</div>
</div>
{{-- 제안서 상태 분포 차트 (Alpine.js + Chart.js) --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div class="bg-white rounded-xl p-5 shadow-sm">
<h3 class="font-semibold text-gray-700 mb-4">📈 제안서 상태 분포</h3>
<canvas id="statusChart" height="200"></canvas>
</div>
<div class="bg-white rounded-xl p-5 shadow-sm">
<h3 class="font-semibold text-gray-700 mb-4">⏰ 최근 제안서</h3>
@foreach($stats['recent_proposals'] as $proposal)
<div class="flex items-center gap-3 py-2 border-b border-gray-50 last:border-0">
<span class="status-badge status-{{ $proposal->status }}">
{{ $proposal->status }}
</span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ $proposal->title }}</p>
<p class="text-xs text-gray-400">
{{ substr($proposal->proposer_address, 0, 6) }}...{{ substr($proposal->proposer_address, -4) }}
</p>
</div>
<span class="text-xs text-gray-400">{{ $proposal->created_at->diffForHumans() }}</span>
</div>
@endforeach
</div>
</div>
@endsection
@push('scripts')
<script>
// Chart.js로 상태 분포 차트 렌더링
const statusData = @json($stats['status_distribution']);
const ctx = document.getElementById('statusChart').getContext('2d');
new Chart(ctx, {
type: 'doughnut',
data: {
labels: Object.keys(statusData),
datasets: [{
data: Object.values(statusData),
backgroundColor: [
'#93c5fd', '#6ee7b7', '#fca5a5',
'#fcd34d', '#c4b5fd', '#f9a8d4'
],
}]
},
options: { responsive: true, plugins: { legend: { position: 'bottom' } } }
});
</script>
@endpush
Livewire를 사용하면 JavaScript 없이도 실시간으로 업데이트되는 컴포넌트를 만들 수 있어. 투표 현황 같은 경우 특히 유용해!
// app/Livewire/VoteTracker.php
namespace App\Livewire;
use App\Models\Proposal;
use App\Services\BlockchainService;
use Livewire\Component;
class VoteTracker extends Component
{
public Proposal $proposal;
public array $voteData = [];
public bool $isLoading = false;
public string $lastUpdated = '';
// 5초마다 자동 폴링
protected $polling = '5000ms';
public function mount(Proposal $proposal): void
{
$this->proposal = $proposal;
$this->loadVoteData();
}
public function loadVoteData(): void
{
$this->isLoading = true;
try {
if ($this->proposal->chain_proposal_id) {
$chainVotes = app(BlockchainService::class)
->getProposalVotes($this->proposal->chain_proposal_id);
$total = array_sum($chainVotes);
$this->voteData = [
'for' => $chainVotes['for'],
'against' => $chainVotes['against'],
'abstain' => $chainVotes['abstain'],
'total' => $total,
'for_pct' => $total > 0 ? round(($chainVotes['for'] / $total) * 100, 1) : 0,
'against_pct' => $total > 0 ? round(($chainVotes['against'] / $total) * 100, 1) : 0,
'abstain_pct' => $total > 0 ? round(($chainVotes['abstain'] / $total) * 100, 1) : 0,
'quorum_pct' => $this->proposal->quorum_required > 0
? round(($total / $this->proposal->quorum_required) * 100, 1)
: 0,
];
// DB 캐시 업데이트
$this->proposal->update([
'votes_for' => $chainVotes['for'],
'votes_against' => $chainVotes['against'],
'votes_abstain' => $chainVotes['abstain'],
'quorum_reached' => $total >= $this->proposal->quorum_required,
]);
}
} catch (\Exception $e) {
// 온체인 조회 실패 시 DB 데이터 사용
$total = $this->proposal->votes_for +
$this->proposal->votes_against +
$this->proposal->votes_abstain;
$this->voteData = [
'for' => $this->proposal->votes_for,
'against' => $this->proposal->votes_against,
'abstain' => $this->proposal->votes_abstain,
'total' => $total,
'for_pct' => $total > 0 ? round(($this->proposal->votes_for / $total) * 100, 1) : 0,
'against_pct' => $total > 0 ? round(($this->proposal->votes_against / $total) * 100, 1) : 0,
'abstain_pct' => $total > 0 ? round(($this->proposal->votes_abstain / $total) * 100, 1) : 0,
'quorum_pct' => 0,
];
}
$this->lastUpdated = now()->format('H:i:s');
$this->isLoading = false;
}
public function render()
{
return view('livewire.vote-tracker');
}
}
{{-- resources/views/livewire/vote-tracker.blade.php --}}
{{--
<div class="bg-white rounded-xl p-5 shadow-sm">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-gray-700">🗳️ 실시간 투표 현황</h3>
<div class="flex items-center gap-2">
@if($isLoading)
<span class="text-xs text-indigo-400 animate-pulse">동기화 중...</span>
@else
<span class="text-xs text-gray-400">{{ $lastUpdated }} 기준</span>
@endif
<button wire:click="loadVoteData" class="text-xs text-indigo-500 hover:text-indigo-700">
🔄 새로고침
</button>
</div>
</div>
@if(!empty($voteData))
<!-- 찬성 바 -->
<div class="mb-3">
<div class="flex justify-between text-sm mb-1">
<span class="text-green-600 font-medium">✅ 찬성</span>
<span class="text-gray-600">{{ number_format($voteData['for'], 2) }} ({{ $voteData['for_pct'] }}%)</span>
</div>
<div class="h-3 bg-gray-100 rounded-full overflow-hidden">
<div class="h-full bg-green-400 rounded-full transition-all duration-500"
style="width: {{ $voteData['for_pct'] }}%"></div>
</div>
</div>
<!-- 반대 바 -->
<div class="mb-3">
<div class="flex justify-between text-sm mb-1">
<span class="text-red-500 font-medium">❌ 반대</span>
<span class="text-gray-600">{{ number_format($voteData['against'], 2) }} ({{ $voteData['against_pct'] }}%)</span>
</div>
<div class="h-3 bg-gray-100 rounded-full overflow-hidden">
<div class="h-full bg-red-400 rounded-full transition-all duration-500"
style="width: {{ $voteData['against_pct'] }}%"></div>
</div>
</div>
<!-- 기권 바 -->
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-500 font-medium">⬜ 기권</span>
<span class="text-gray-600">{{ number_format($voteData['abstain'], 2) }} ({{ $voteData['abstain_pct'] }}%)</span>
</div>
<div class="h-3 bg-gray-100 rounded-full overflow-hidden">
<div class="h-full bg-gray-300 rounded-full transition-all duration-500"
style="width: {{ $voteData['abstain_pct'] }}%"></div>
</div>
</div>
<!-- 정족수 달성 현황 -->
<div class="border-t pt-3">
<div class="flex justify-between text-sm mb-1">
<span class="font-medium text-gray-600">📊 정족수 달성률</span>
<span class="{{ $voteData['quorum_pct'] >= 100 ? 'text-green-600 font-bold' : 'text-gray-600' }}">
{{ min($voteData['quorum_pct'], 100) }}%
@if($voteData['quorum_pct'] >= 100) ✅ @endif
</span>
</div>
<div class="h-2 bg-gray-100 rounded-full overflow-hidden">
<div class="h-full bg-indigo-400 rounded-full transition-all duration-500"
style="width: {{ min($voteData['quorum_pct'], 100) }}%"></div>
</div>
</div>
@endif
</div>
--}}
DAO 어드민 시스템에서 보안은 정말 중요해. 잘못된 권한 설정으로 악의적인 상태 변경이 일어나면 커뮤니티 신뢰가 무너질 수 있거든.
// app/Policies/ProposalPolicy.php
namespace App\Policies;
use App\Models\Proposal;
use App\Models\User;
class ProposalPolicy
{
/**
* 제안서 목록 조회 - 모든 어드민 가능
*/
public function viewAny(User $user): bool
{
return $user->hasAnyRole(['super_admin', 'admin', 'moderator', 'viewer']);
}
/**
* 제안서 상세 조회
*/
public function view(User $user, Proposal $proposal): bool
{
return $user->hasAnyRole(['super_admin', 'admin', 'moderator', 'viewer']);
}
/**
* 제안서 생성 - 어드민 이상
*/
public function create(User $user): bool
{
return $user->hasAnyRole(['super_admin', 'admin']);
}
/**
* 제안서 수정 - 어드민 이상 또는 모더레이터(초안만)
*/
public function update(User $user, Proposal $proposal): bool
{
if ($user->hasAnyRole(['super_admin', 'admin'])) {
return true;
}
// 모더레이터는 초안/검토대기 상태만 수정 가능
if ($user->hasRole('moderator')) {
return in_array($proposal->status, ['draft', 'pending']);
}
return false;
}
/**
* 상태 변경 - 어드민 이상
*/
public function updateStatus(User $user, Proposal $proposal): bool
{
return $user->hasAnyRole(['super_admin', 'admin']);
}
/**
* 온체인 동기화 - 어드민 이상
*/
public function sync(User $user, Proposal $proposal): bool
{
return $user->hasAnyRole(['super_admin', 'admin']);
}
/**
* 삭제 - 슈퍼어드민만
*/
public function delete(User $user, Proposal $proposal): bool
{
return $user->hasRole('super_admin');
}
/**
* 실행 큐 등록 - 슈퍼어드민만
*/
public function queue(User $user, Proposal $proposal): bool
{
return $user->hasRole('super_admin')
&& $proposal->status === 'succeeded';
}
}
// app/Http/Middleware/AdminAuth.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class AdminAuth
{
public function handle(Request $request, Closure $next)
{
if (!auth()->check()) {
return redirect()->route('login');
}
if (!auth()->user()->hasAnyRole(['super_admin', 'admin', 'moderator', 'viewer'])) {
abort(403, '어드민 접근 권한이 없습니다.');
}
// 2FA 체크 (어드민 이상은 필수)
if (auth()->user()->hasAnyRole(['super_admin', 'admin'])) {
if (!session('2fa_verified')) {
return redirect()->route('admin.2fa.verify');
}
}
return $next($request);
}
}
블록체인 데이터 동기화는 시간이 걸리는 작업이야. 이걸 동기적으로 처리하면 사용자 경험이 끔찍해지니까 Queue + Scheduler 조합으로 비동기 처리해야 해.
// app/Jobs/SyncProposalJob.php
namespace App\Jobs;
use App\Models\Proposal;
use App\Services\BlockchainService;
use App\Services\IpfsService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class SyncProposalJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
// 최대 3번 재시도
public int $tries = 3;
// 재시도 간격 (초)
public int $backoff = 60;
// 타임아웃 30초
public int $timeout = 30;
public function __construct(
private int $proposalId
) {}
public function handle(
BlockchainService $blockchainService,
IpfsService $ipfsService
): void {
$proposal = Proposal::findOrFail($this->proposalId);
if (!$proposal->chain_proposal_id) {
Log::warning('체인 ID 없는 제안서 동기화 시도', ['id' => $this->proposalId]);
return;
}
try {
// 1. 온체인 상태 동기화
$chainState = $blockchainService->getProposalState($proposal->chain_proposal_id);
$chainVotes = $blockchainService->getProposalVotes($proposal->chain_proposal_id);
$updateData = [
'status' => $blockchainService->mapChainStateToDbStatus($chainState),
'votes_for' => $chainVotes['for'] ?? 0,
'votes_against' => $chainVotes['against'] ?? 0,
'votes_abstain' => $chainVotes['abstain'] ?? 0,
];
// 2. IPFS 내용 로드 (아직 안 된 경우)
if ($proposal->ipfs_hash && $proposal->summary === 'IPFS에서 내용을 불러오는 중...') {
$ipfsData = $ipfsService->getJson($proposal->ipfs_hash);
if ($ipfsData) {
$updateData['title'] = $ipfsData['title'] ?? $proposal->title;
$updateData['summary'] = $ipfsData['summary'] ?? substr($ipfsData['description'] ?? '', 0, 500);
$updateData['description'] = $ipfsData['description'] ?? null;
}
}
$proposal->update($updateData);
Log::info('제안서 동기화 완료', [
'proposal_id' => $this->proposalId,
'chain_id' => $proposal->chain_proposal_id,
'new_status' => $updateData['status'],
]);
} catch (\Exception $e) {
Log::error('제안서 동기화 실패', [
'proposal_id' => $this->proposalId,
'error' => $e->getMessage(),
]);
// 재시도 가능한 예외면 다시 던지기
throw $e;
}
}
public function failed(\Throwable $exception): void
{
Log::error('제안서 동기화 최종 실패', [
'proposal_id' => $this->proposalId,
'error' => $exception->getMessage(),
]);
}
}
// app/Console/Commands/SyncProposalsFromChain.php
namespace App\Console\Commands;
use App\Models\Proposal;
use App\Jobs\SyncProposalJob;
use Illuminate\Console\Command;
class SyncProposalsFromChain extends Command
{
protected $signature = 'dao:sync-proposals
{--status=active : 동기화할 상태 (active/all)}
{--limit=50 : 최대 동기화 수}';
protected $description = '온체인 제안서 데이터를 DB와 동기화합니다';
public function handle(): int
{
$status = $this->option('status');
$limit = (int) $this->option('limit');
$query = Proposal::whereNotNull('chain_proposal_id');
if ($status !== 'all') {
$query->whereIn('status', ['pending', 'active', 'succeeded', 'queued']);
}
$proposals = $query->limit($limit)->get();
$this->info("🔄 {$proposals->count()}개 제안서 동기화 시작...");
$bar = $this->output->createProgressBar($proposals->count());
$bar->start();
foreach ($proposals as $proposal) {
SyncProposalJob::dispatch($proposal->id);
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info('✅ 동기화 작업이 큐에 등록되었습니다!');
return Command::SUCCESS;
}
}
// app/Console/Kernel.php - 스케줄러 등록
protected function schedule(Schedule $schedule): void
{
// 5분마다 활성 제안서 동기화
$schedule->command('dao:sync-proposals --status=active')
->everyFiveMinutes()
->withoutOverlapping()
->runInBackground();
// 1시간마다 전체 제안서 동기화
$schedule->command('dao:sync-proposals --status=all --limit=100')
->hourly()
->withoutOverlapping();
// 매일 자정 멤버 토큰 잔액 스냅샷
$schedule->command('dao:snapshot-balances')
->dailyAt('00:00')
->withoutOverlapping();
// 매주 월요일 통계 리포트 생성
$schedule->command('dao:generate-weekly-report')
->weeklyOn(1, '09:00');
}
DAO 제안서의 상세 내용은 보통 IPFS에 저장해. 블록체인에 긴 텍스트를 직접 저장하면 가스비가 엄청나게 들거든. 라라벨에서 IPFS 연동하는 방법을 알아보자.
// app/Services/IpfsService.php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;
class IpfsService
{
private string $gatewayUrl;
private string $apiUrl;
private ?string $pinataApiKey;
private ?string $pinataSecretKey;
public function __construct()
{
$this->gatewayUrl = config('dao.ipfs_gateway', 'https://ipfs.io/ipfs/');
$this->apiUrl = config('dao.ipfs_api_url', 'http://localhost:5001');
$this->pinataApiKey = config('dao.pinata_api_key');
$this->pinataSecretKey = config('dao.pinata_secret_key');
}
/**
* JSON 데이터를 IPFS에 업로드
* Pinata API 사용 (프로덕션 권장)
*/
public function uploadJson(array $data): ?string
{
try {
if ($this->pinataApiKey) {
return $this->uploadToPinata($data);
}
return $this->uploadToLocalNode($data);
} catch (\Exception $e) {
Log::error('IPFS 업로드 실패', ['error' => $e->getMessage()]);
return null;
}
}
/**
* Pinata를 통한 IPFS 업로드
*/
private function uploadToPinata(array $data): string
{
$response = Http::withHeaders([
'pinata_api_key' => $this->pinataApiKey,
'pinata_secret_api_key' => $this->pinataSecretKey,
])->post('https://api.pinata.cloud/pinning/pinJSONToIPFS', [
'pinataContent' => $data,
'pinataMetadata' => [
'name' => 'dao-proposal-' . time(),
],
]);
if (!$response->successful()) {
throw new \Exception('Pinata 업로드 실패: ' . $response->body());
}
return $response->json('IpfsHash');
}
/**
* 로컬 IPFS 노드에 업로드
*/
private function uploadToLocalNode(array $data): string
{
$response = Http::attach(
'file',
json_encode($data),
'proposal.json'
)->post("{$this->apiUrl}/api/v0/add");
if (!$response->successful()) {
throw new \Exception('IPFS 로컬 노드 업로드 실패');
}
return $response->json('Hash');
}
/**
* IPFS에서 JSON 데이터 가져오기
*/
public function getJson(string $hash): ?array
{
$cacheKey = "ipfs_{$hash}";
return Cache::remember($cacheKey, 3600, function () use ($hash) {
try {
// 여러 게이트웨이 시도 (폴백)
$gateways = [
"https://ipfs.io/ipfs/{$hash}",
"https://cloudflare-ipfs.com/ipfs/{$hash}",
"https://gateway.pinata.cloud/ipfs/{$hash}",
];
foreach ($gateways as $url) {
$response = Http::timeout(10)->get($url);
if ($response->successful()) {
return $response->json();
}
}
return null;
} catch (\Exception $e) {
Log::warning('IPFS 데이터 조회 실패', [
'hash' => $hash,
'error' => $e->getMessage(),
]);
return null;
}
});
}
/**
* IPFS 해시로 게이트웨이 URL 생성
*/
public function getGatewayUrl(string $hash): string
{
return $this->gatewayUrl . $hash;
}
}
어드민 시스템에서 통계 대시보드는 빠질 수 없지. 거버넌스 참여율, 제안 성공률, 활성 멤버 추이 등을 시각화해보자.
// app/Http/Controllers/Admin/DashboardController.php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Proposal;
use App\Models\Vote;
use App\Models\DaoMember;
use App\Services\ProposalService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
class DashboardController extends Controller
{
public function __construct(
private ProposalService $proposalService
) {}
public function index()
{
$stats = $this->proposalService->getDashboardStats();
return view('admin.dashboard.index', compact('stats'));
}
/**
* 실시간 통계 API (AJAX 폴링용)
*/
public function realtimeStats(): JsonResponse
{
$data = cache()->remember('realtime_stats', 60, function () {
return [
'active_proposals' => Proposal::where('status', 'active')->count(),
'pending_review' => Proposal::where('status', 'pending')->count(),
'votes_today' => Vote::whereDate('voted_at', today())->count(),
'new_members_week' => DaoMember::where('joined_at', '>=', now()->subWeek())->count(),
];
});
return response()->json($data);
}
/**
* 월별 제안서 통계
*/
public function monthlyStats(): JsonResponse
{
$data = Proposal::selectRaw('
YEAR(created_at) as year,
MONTH(created_at) as month,
COUNT(*) as total,
SUM(CASE WHEN status IN ("succeeded", "executed") THEN 1 ELSE 0 END) as passed,
SUM(CASE WHEN status = "defeated" THEN 1 ELSE 0 END) as defeated
')
->where('created_at', '>=', now()->subMonths(12))
->groupBy('year', 'month')
->orderBy('year')
->orderBy('month')
->get();
return response()->json($data);
}
/**
* 참여율 추이
*/
public function participationTrend(): JsonResponse
{
$data = Proposal::with('votes')
->where('status', 'not in', ['draft', 'pending'])
->where('created_at', '>=', now()->subMonths(6))
->get()
->map(function ($proposal) {
$totalVotingPower = $proposal->votes->sum('voting_power');
return [
'date' => $proposal->voting_start_at?->format('Y-m-d'),
'title' => substr($proposal->title, 0, 30),
'voters_count' => $proposal->votes->count(),
'total_voting_power' => $totalVotingPower,
];
});
return response()->json($data);
}
/**
* 공개 API용 통계 (외부 대시보드 연동)
*/
public function apiStats(): JsonResponse
{
return response()->json([
'total_proposals' => Proposal::whereNotIn('status', ['draft'])->count(),
'active_proposals' => Proposal::where('status', 'active')->count(),
'total_votes' => Vote::count(),
'unique_voters' => Vote::distinct('voter_address')->count('voter_address'),
'pass_rate' => $this->calculatePassRate(),
'avg_participation' => DaoMember::avg('participation_rate'),
'updated_at' => now()->toIso8601String(),
]);
}
private function calculatePassRate(): float
{
$total = Proposal::whereIn('status', ['succeeded', 'executed', 'defeated'])->count();
if ($total === 0) return 0;
$passed = Proposal::whereIn('status', ['succeeded', 'executed'])->count();
return round(($passed / $total) * 100, 1);
}
}
개발이 끝났으면 이제 배포할 차례야! DAO 어드민 시스템은 보안이 중요하니까 배포 설정도 꼼꼼하게 해야 해.
# .env 설정 예시
APP_NAME="DAO Admin"
APP_ENV=production
APP_DEBUG=false
APP_URL=https://admin.yourdao.xyz
# 데이터베이스
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=dao_admin
DB_USERNAME=dao_user
DB_PASSWORD=your_secure_password
# Redis (큐 & 캐시)
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
# DAO 블록체인 설정
DAO_RPC_URL=https://mainnet.infura.io/v3/YOUR_INFURA_KEY
DAO_GOVERNANCE_CONTRACT=0x...
DAO_CHAIN_ID=1
# IPFS 설정
DAO_IPFS_GATEWAY=https://ipfs.io/ipfs/
PINATA_API_KEY=your_pinata_key
PINATA_SECRET_KEY=your_pinata_secret
# 알림 설정
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailgun.org
MAIL_PORT=587
SLACK_WEBHOOK_URL=https://hooks.slack.com/...
# 보안
SESSION_LIFETIME=120
SANCTUM_STATEFUL_DOMAINS=admin.yourdao.xyz
# docker-compose.yml
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: dao_admin_app
restart: unless-stopped
working_dir: /var/www
volumes:
- ./:/var/www
- ./storage:/var/www/storage
networks:
- dao_network
depends_on:
- db
- redis
nginx:
image: nginx:alpine
container_name: dao_admin_nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./:/var/www
- ./docker/nginx:/etc/nginx/conf.d
- ./docker/ssl:/etc/ssl/certs
networks:
- dao_network
depends_on:
- app
db:
image: mysql:8.0
container_name: dao_admin_db
restart: unless-stopped
environment:
MYSQL_DATABASE: dao_admin
MYSQL_USER: dao_user
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- dao_db_data:/var/lib/mysql
networks:
- dao_network
redis:
image: redis:7-alpine
container_name: dao_admin_redis
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- dao_redis_data:/data
networks:
- dao_network
queue_worker:
build:
context: .
dockerfile: Dockerfile
container_name: dao_admin_queue
restart: unless-stopped
working_dir: /var/www
command: php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
volumes:
- ./:/var/www
networks:
- dao_network
depends_on:
- app
- redis
scheduler:
build:
context: .
dockerfile: Dockerfile
container_name: dao_admin_scheduler
restart: unless-stopped
working_dir: /var/www
command: sh -c "while true; do php artisan schedule:run; sleep 60; done"
volumes:
- ./:/var/www
networks:
- dao_network
depends_on:
- app
networks:
dao_network:
driver: bridge
volumes:
dao_db_data:
dao_redis_data:
DAO 시스템에서 버그는 단순한 불편함이 아니라 실제 거버넌스 결과에 영향을 줄 수 있어. 그래서 테스트 코드는 선택이 아니라 필수야!
// tests/Feature/ProposalManagementTest.php
namespace Tests\Feature;
use App\Models\Proposal;
use App\Models\User;
use App\Models\DaoMember;
use App\Services\BlockchainService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class ProposalManagementTest extends TestCase
{
use RefreshDatabase;
private User $adminUser;
private User $moderatorUser;
private User $viewerUser;
protected function setUp(): void
{
parent::setUp();
$this->adminUser = User::factory()->create(['role' => 'admin']);
$this->moderatorUser = User::factory()->create(['role' => 'moderator']);
$this->viewerUser = User::factory()->create(['role' => 'viewer']);
}
/** @test */
public function 어드민은_제안서_목록을_조회할_수_있다(): void
{
Proposal::factory()->count(5)->create();
$this->actingAs($this->adminUser)
->get(route('admin.proposals.index'))
->assertOk()
->assertViewHas('proposals');
}
/** @test */
public function 어드민은_제안서_상태를_변경할_수_있다(): void
{
$proposal = Proposal::factory()->create(['status' => 'pending']);
$this->actingAs($this->adminUser)
->patch(route('admin.proposals.status', $proposal), [
'status' => 'active',
'admin_note' => '검토 완료, 투표 시작',
])
->assertRedirect()
->assertSessionHas('success');
$this->assertDatabaseHas('proposals', [
'id' => $proposal->id,
'status' => 'active',
]);
}
/** @test */
public function 뷰어는_제안서_상태를_변경할_수_없다(): void
{
$proposal = Proposal::factory()->create(['status' => 'pending']);
$this->actingAs($this->viewerUser)
->patch(route('admin.proposals.status', $proposal), [
'status' => 'active',
])
->assertForbidden();
}
/** @test */
public function 온체인_동기화가_정상_작동한다(): void
{
// BlockchainService 모킹
$mockBlockchain = Mockery::mock(BlockchainService::class);
$mockBlockchain->shouldReceive('getProposalState')
->once()
->andReturn(1); // Active
$mockBlockchain->shouldReceive('getProposalVotes')
->once()
->andReturn([
'for' => '1000.5',
'against' => '200.3',
'abstain' => '50.0',
]);
$mockBlockchain->shouldReceive('mapChainStateToDbStatus')
->once()
->andReturn('active');
$this->app->instance(BlockchainService::class, $mockBlockchain);
$proposal = Proposal::factory()->create([
'chain_proposal_id' => '42',
'status' => 'pending',
]);
$this->actingAs($this->adminUser)
->post(route('admin.proposals.sync', $proposal))
->assertRedirect()
->assertSessionHas('success');
$this->assertDatabaseHas('proposals', [
'id' => $proposal->id,
'status' => 'active',
'votes_for' => '1000.5',
]);
}
/** @test */
public function 대시보드_통계가_올바르게_계산된다(): void
{
Proposal::factory()->count(10)->create(['status' => 'active']);
Proposal::factory()->count(5)->create(['status' => 'succeeded']);
Proposal::factory()->count(3)->create(['status' => 'defeated']);
$this->actingAs($this->adminUser)
->get(route('admin.dashboard'))
->assertOk()
->assertViewHas('stats', function ($stats) {
return $stats['active_proposals'] === 10
&& $stats['passed_proposals'] === 5;
});
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
}
이론은 충분히 다뤘으니까, 실제로 운영하면서 자주 마주치는 문제들과 해결책을 공유할게. 이게 진짜 꿀팁이야! 🍯
해결책:
- 캐시 TTL을 적절히 설정 (활성 제안은 30초, 완료된 제안은 1시간)
- 큐 작업에
throttle 미들웨어 적용- 여러 RPC 엔드포인트를 로드밸런싱으로 사용
- The Graph 같은 인덱싱 서비스 활용
해결책:
- 어드민 UI에 "온체인 검증" 버튼 제공
- 상태 변경 시 항상 온체인 상태를 먼저 확인
- 불일치 감지 시 자동 알림 발송
- 감사 로그로 모든 변경 이력 추적
해결책:
- 투표 목록은 커서 기반 페이지네이션 사용
- 집계 데이터(총 투표수, 비율)는 별도 컬럼에 캐시
- 투표 상세 조회는 Elasticsearch 활용 고려
- 인덱스 최적화: (proposal_id, support), (voter_address)
해결책:
- 여러 게이트웨이 폴백 체인 구성
- 한 번 로드된 내용은 DB에 캐시 저장
- 로딩 중 상태를 UI에 명확히 표시
- 중요 제안서는 자체 IPFS 노드에 핀(pin) 고정
DAO 어드민 시스템이 커질수록 성능 이슈가 생기기 마련이야. 미리 체크해두면 나중에 고생 안 해도 돼!
| 영역 | 최적화 항목 | 우선순위 |
|---|---|---|
| 🗄️ 데이터베이스 | N+1 쿼리 방지 (Eager Loading), 복합 인덱스 설정, 쿼리 캐싱 | ⭐⭐⭐ 높음 |
| ⚡ 캐싱 | Redis 캐시 레이어, 대시보드 통계 5분 캐시, IPFS 콘텐츠 1시간 캐시 | ⭐⭐⭐ 높음 |
| 🔗 블록체인 RPC | 배치 요청 활용, 멀티콜 컨트랙트 사용, 결과 캐싱 | ⭐⭐⭐ 높음 |
| 📄 페이지네이션 | 커서 기반 페이지네이션 (대용량 데이터), Lazy Loading | ⭐⭐ 중간 |
| 🖼️ 프론트엔드 | Vite 번들 최적화, 이미지 지연 로딩, 컴포넌트 지연 로딩 | ⭐⭐ 중간 |
| 🔄 큐 처리 | 큐 워커 수 조정, 우선순위 큐 설정, 실패 작업 모니터링 | ⭐⭐ 중간 |
| 🔍 검색 | Full-text 인덱스, Elasticsearch 도입 (대규모 시) | ⭐ 낮음 |
// ❌ N+1 문제 발생 코드
$proposals = Proposal::all();
foreach ($proposals as $proposal) {
echo $proposal->reviewer->name; // 매번 쿼리 발생!
echo $proposal->votes->count(); // 또 쿼리!
}
// ✅ Eager Loading으로 해결
$proposals = Proposal::with(['reviewer', 'votes'])
->withCount('votes')
->get();
foreach ($proposals as $proposal) {
echo $proposal->reviewer?->name; // 추가 쿼리 없음
echo $proposal->votes_count; // 이미 로드됨
}
// ✅ 대용량 데이터는 청크 처리
Proposal::with('votes')
->where('status', 'active')
->chunk(100, function ($proposals) {
foreach ($proposals as $proposal) {
// 처리 로직
}
});
// ✅ 특정 컬럼만 선택해서 메모리 절약
$proposals = Proposal::select(['id', 'title', 'status', 'created_at'])
->with(['reviewer:id,name'])
->paginate(20);
어드민 페이지라고 해서 모바일을 무시하면 안 돼. 요즘은 이동 중에도 긴급한 제안서 상태 변경이 필요할 수 있거든!
2. 터치 친화적 버튼 — 최소 44px × 44px 터치 영역 확보
3. 테이블 대신 카드 레이아웃 — 좁은 화면에서 가로 스크롤 방지
4. 중요 액션 우선 배치 — 상태 변경, 동기화 버튼은 스크롤 없이 접근 가능하게
5. 오프라인 대응 — Service Worker로 기본 데이터 캐싱
{{-- 모바일 반응형 제안서 카드 (테이블 대신) --}}
{{-- 데스크톱: 테이블 / 모바일: 카드 --}}
<div class="hidden md:block">
{{-- 데스크톱 테이블 뷰 --}}
<table class="w-full">
<thead>
<tr>
<th>제목</th>
<th>상태</th>
<th>제안자</th>
<th>투표 현황</th>
<th>액션</th>
</tr>
</thead>
<tbody>
@foreach($proposals as $proposal)
<tr>
<td>{{ $proposal->title }}</td>
<td><span class="status-badge">{{ $proposal->status }}</span></td>
<td class="font-mono text-sm">
{{ substr($proposal->proposer_address, 0, 6) }}...
</td>
<td>
<div class="flex gap-2 text-sm">
<span class="text-green-600">✅ {{ number_format($proposal->votes_for, 0) }}</span>
<span class="text-red-500">❌ {{ number_format($proposal->votes_against, 0) }}</span>
</div>
</td>
<td>
<a href="{{ route('admin.proposals.show', $proposal) }}"
class="btn-sm">상세</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="md:hidden space-y-3">
{{-- 모바일 카드 뷰 --}}
@foreach($proposals as $proposal)
<div class="bg-white rounded-xl p-4 shadow-sm border border-gray-100">
<div class="flex items-start justify-between mb-2">
<h3 class="font-medium text-gray-800 flex-1 mr-2 line-clamp-2">
{{ $proposal->title }}
</h3>
<span class="status-badge status-{{ $proposal->status }} shrink-0">
{{ $proposal->status }}
</span>
</div>
<p class="text-xs text-gray-400 font-mono mb-3">
{{ substr($proposal->proposer_address, 0, 10) }}...
</p>
<div class="flex items-center justify-between">
<div class="flex gap-3 text-sm">
<span class="text-green-600">✅ {{ number_format($proposal->votes_for, 0) }}</span>
<span class="text-red-500">❌ {{ number_format($proposal->votes_against, 0) }}</span>
</div>
<a href="{{ route('admin.proposals.show', $proposal) }}"
class="text-indigo-600 text-sm font-medium">
상세 보기 →
</a>
</div>
</div>
@endforeach
</div>
자, 이제 라라벨 기반 DAO 제안서 관리 어드민 시스템의 전체 그림이 그려졌지? 마지막으로 전체 흐름을 한 번 더 정리해볼게.
이렇게 라라벨 기반 DAO 제안서 관리 어드민 시스템을 처음부터 끝까지 살펴봤어. 핵심을 정리하면:
2. 데이터 설계 — 온체인 ID와 오프체인 메타데이터를 함께 관리, 투표 결과는 캐시로 저장
3. 블록체인 연동 — web3p/web3.php로 RPC 통신, 캐시 레이어로 성능 최적화
4. 비동기 처리 — Queue + Scheduler로 동기화 작업 비동기 처리
5. 보안 — Policy 기반 권한 제어, 2FA, 감사 로그 필수
6. UI — Livewire + Alpine.js로 실시간 투표 현황 구현
7. 운영 — Docker Compose로 컨테이너화, 모니터링 체계 구축
DAO 어드민 시스템은 단순한 CRUD 앱이 아니야. 블록체인 데이터와 오프체인 데이터를 유기적으로 연결하면서, 커뮤니티의 신뢰를 유지하는 투명한 거버넌스 도구야. 라라벨의 강력한 생태계를 활용하면 이런 복잡한 시스템도 충분히 구축할 수 있어!
혹시 특정 부분에서 막히거나 더 깊이 파고 싶은 부분이 있다면, 재능넷에서 Web3 전문 개발자를 찾아서 도움을 받는 것도 좋은 방법이야. 복잡한 시스템일수록 경험 있는 사람의 조언이 정말 큰 도움이 되거든! 🚀
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.