(技術)大型關係管理Monica
|字數總計:2.6k|閱讀時長:11分鐘|閱讀量:
Monica 是一個開源專案,用於組織和記錄與親人的互動。又稱 PRM,個人關係管理。可將其視為您朋友或家人的 CRM,本文會介紹感興趣的功能,API、數據導出、OAuth、以及 issue。
Monica’s vision is to help people have more meaningful relationships.
幫助人們建立有意義的關係
主打”關係管理”,相較市面成熟的社交產品(如 Facebook),其定位特別,替人脈建立一個管理後台,還記得大學時參加幹訓、社團迎新,認識各社團幹部,常忘記小細節,此產品便能很好地解決這個問題,基於興趣,我也作為翻譯貢獻者,協助 monica 文件繁體中文的在地化,具體參考chienniman/monica。
本文會介紹API授權(個人使用、開放授權),數據導出、社群討論issue,也會同時附上以上功能的原始碼分析,深入淺出的介紹設計模式在本專案的應用。
執行&部署
1 2 3 4
| PHP 8.1+ HTTP server with PHP support (eg: Apache, Nginx, Caddy) Composer MySQL
|
官方文件提到,構建專案程式需要 1.5GB RAM 以上,在 GCP 開便宜的 n1-standard-1,每月最少也要 600 台幣
Authentication
在 Monica 中,OAuth 2.0 與個人訪問令牌是兩種不同的身份驗證機制,但它們都用於授權 API 訪問。
OAuth 2.0 是一種標準的開放授權協議,允許用戶在 Monica 上授權第三方應用程序訪問數據,不需要將用戶名和密碼提供給該應用程序。當用戶通過 OAuth 2.0 授權授權應用程序時,該應用程序會收到一個訪問令牌,以便它可以代表用戶訪問 Monica API。
個人訪問令牌則是一種基於 Monica 賬戶的令牌,允許應用程序代表用戶訪問 Monica API。這意味著,當用戶提供他們的個人訪問令牌給應用程序時,該應用程序就可以代表該用戶訪問 API,就像它擁有 OAuth 2.0 訪問令牌一樣。
雖然這兩種令牌都可以用於授權 API 訪問,但它們的使用方式略有不同。 OAuth 2.0 是一種標準的開放授權協議,允許用戶授權第三方應用程序訪問其數據。個人訪問令牌則是一種在 Monica 內部生成的令牌,只能由用戶本人使用。在大多數情況下,建議使用 OAuth 2.0,因為它是更安全和更靈活的身份驗證協議。
個人驗證授權
1
| curl -H "Authorization: Bearer Personal access token" https://app.monicahq.com/api
|
個人驗證令牌(personal-access-token)

將 token 放在 postmon 的 Authorization
postman測試

開放授權(OAuth)
monica 也提供 OAuth 方式驗證 API,向伺服器發起驗證請求,跳出授權允許視窗,取得 Access Token,之後請求夾帶令牌訪問保護資源
流程

Postmon

配置 postmon 參數,將 Callback URL 填入 monica 後台 API,Token name、Client ID、Client Secret 填入 postmon
授權請求

postmon 會跳出授權請求,允許後核發 Access Token、Refresh Token,點擊使用後自動帶入 Authorization Token 欄位
測試請求

The OAuth 2.0 Authorization Framework: Bearer Token Usage
monica-api 文件
第三方登入
官方託管 monica 支持 OAuth API,卻不支持 Facebook、Google 第三方登入,主流的平台為提升用戶體驗,通常會支持,疑惑地查找社群討論發現

degan6 提出 OAuth 登入的 pull request,但被主要開發者拒絕了

1.不想要支持有疑慮的第三方登入(Facebook 疑似洩漏個資事件)
2.官方託管已經移除大多數的追蹤程式碼
數據導出
monica 能輸出聯絡人vCard,使用者Sql、Json 等,相當便利,數據輸出需較長時間處理,隊列任務被存儲在數據庫,在多個請求之間共享任務。當任務被添加到隊列,被插入到表中,等待被執行。Laravel會跟蹤任務的狀態,未處理、處理中、已處理、失敗。
Laravel預設使用同步隊列(sync),保證實時、穩定性,方便進行開發與測試,但對於大量、耗時任務,同步處理會阻塞主線程,因此不適合高併發場景。
vCard

vCard 是電子名片的文件格式標準。它一般附加在電子郵件之後,但也可以用於其它場合(如在網際網路上相互交換)。vCard 可包含的信息有:姓名、地址資訊、電話號碼、URL,logo,相片等。
Routes
1 2
| Route::get('/people/{contact}/vcard', 'ContactsController@vcard')->name('vcard');
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
|
public function vCard(Contact $contact) { if (config('app.debug') && class_exists('\Barryvdh\Debugbar\Facade')) { Debugbar::disable(); } $vcard = app(ExportVCard::class)->execute([ 'account_id' => auth()->user()->account_id, 'contact_id' => $contact->id, ]);
return response($vcard->serialize()) ->header('Content-type', 'text/x-vcard') ->header('Content-Disposition', 'attachment; filename='.Str::slug($contact->name, '-', LocaleHelper::getLang()).'.vcf'); }
|
Sql & Json導出

Routes
1 2 3
| Route::post('/settings/exportToSql', 'Settings\\ExportController@storeSQL')->name('export.store.sql'); Route::post('/settings/exportToJson', 'Settings\\ExportController@storeJson')->name('export.store.json');
|
ExportJob
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| namespace App\Models\Account;
use App\Traits\HasUuid; use App\Models\User\User; use Illuminate\Database\Eloquent\Model; use App\Notifications\ExportAccountDone; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Factories\HasFactory;
class ExportJob extends Model { use HasUuid, HasFactory;
public const EXPORT_TODO = 'todo'; public const EXPORT_DOING = 'doing'; public const EXPORT_DONE = 'done'; public const EXPORT_FAILED = 'failed';
public const SQL = 'sql';
public const JSON = 'json';
protected $fillable = [ 'uuid', 'account_id', 'user_id', 'type', 'status', 'filesystem', 'filename', 'started_at', 'ended_at', ];
protected $guarded = ['id'];
protected $dates = [ 'started_at', 'ended_at', ];
public function account() { return $this->belongsTo(Account::class); }
public function user() { return $this->belongsTo(User::class); }
public function start(): void { $this->status = self::EXPORT_DOING; $this->started_at = now(); $this->save(); }
public function end(): void { $this->status = self::EXPORT_DONE; $this->ended_at = now(); $this->save(); $this->user->notify(new ExportAccountDone($this)); } }
|
ExportController
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| private function newExport(string $type): ExportJob { $exports = ExportJob::where([ 'account_id' => auth()->user()->account_id, 'user_id' => auth()->user()->id, ]) ->orderBy('created_at') ->get(); if ($exports->count() >= config('monica.export_size')) { $job = $exports->first(); try { if ($job->filename !== null) { StorageHelper::disk($job->location) ->delete($job->filename); } } finally { $job->delete(); } } return ExportJob::create([ 'account_id' => auth()->user()->account_id, 'user_id' => auth()->user()->id, 'type' => $type, ]); }
public function storeSql() { $job = $this->newExport(ExportJob::SQL); ExportAccount::dispatch($job);
return redirect()->route('settings.export.index') ->withStatus(trans('settings.export_submitted')); }
public function storeJson() { $job = $this->newExport(ExportJob::JSON); ExportAccount::dispatch($job);
return redirect()->route('settings.export.index') ->withStatus(trans('settings.export_submitted'));
}
|
Dispatch
1 2 3 4 5
| public static function dispatch(...$arguments) { return new PendingDispatch(new static(...$arguments)); }
|
PendingDispatch
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116
|
namespace Illuminate\Foundation\Bus;
use Illuminate\Bus\UniqueLock; use Illuminate\Container\Container; use Illuminate\Contracts\Bus\Dispatcher; use Illuminate\Contracts\Cache\Repository as Cache; use Illuminate\Contracts\Queue\ShouldBeUnique;
class PendingDispatch { protected $job;
protected $afterResponse = false;
public function __construct($job) { $this->job = $job; }
public function onConnection($connection) { $this->job->onConnection($connection);
return $this; }
public function onQueue($queue) { $this->job->onQueue($queue);
return $this; }
public function allOnConnection($connection) { $this->job->allOnConnection($connection);
return $this; }
public function allOnQueue($queue) { $this->job->allOnQueue($queue);
return $this; }
public function delay($delay) { $this->job->delay($delay);
return $this; }
public function afterCommit() { $this->job->afterCommit();
return $this; }
public function beforeCommit() { $this->job->beforeCommit();
return $this; }
public function chain($chain) { $this->job->chain($chain);
return $this; }
public function afterResponse() { $this->afterResponse = true;
return $this; }
protected function shouldDispatch() { if (! $this->job instanceof ShouldBeUnique) { return true; }
return (new UniqueLock(Container::getInstance()->make(Cache::class))) ->acquire($this->job); }
public function __call($method, $parameters) { $this->job->{$method}(...$parameters);
return $this; } public function __destruct() { if (! $this->shouldDispatch()) { return; } elseif ($this->afterResponse) { app(Dispatcher::class)->dispatchAfterResponse($this->job); } else { app(Dispatcher::class)->dispatch($this->job); } } }
|
成功通知

發信notify
1 2 3 4 5 6 7 8 9
| public function end(): void { $this->status = self::EXPORT_DONE; $this->ended_at = now(); $this->save();
$this->user->notify(new ExportAccountDone($this)); }
|
ExportAccountDone
1 2 3 4 5 6 7 8 9 10 11 12 13
| public function toMail(User $user): MailMessage { $date = Carbon::parse($this->exportJob->created_at) ->setTimezone($user->timezone); return (new MailMessage) ->success() ->subject(trans('mail.export_title')) ->greeting(trans('mail.greetings', ['username' => $user->first_name])) ->line(trans('mail.export_description', ['date' => DateHelper::getShortDate($date)])) ->action(trans('mail.export_download'), route('settings.export.index')); }
|
下載資源
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| public function download(Request $request, string $uuid) { $job = ExportJob::where([ 'account_id' => auth()->user()->account_id, 'user_id' => auth()->user()->id, 'uuid' => $uuid, ])->firstOrFail();
if ($job->status !== ExportJob::EXPORT_DONE) { return redirect()->route('settings.export.index') ->withErrors(trans('settings.export_not_done')); } $disk = StorageHelper::disk($job->location);
return $disk->response($job->filename, "monica.{$job->type}", [ 'Content-Type' => "application/{$job->type}; charset=utf-8", 'Content-Disposition' => "attachment; filename=monica.{$job->type}", ] ); }
|