Add a read-only Google AdSense analytics module for admins.

Connect AdSense over OAuth, sync entities and daily reports locally, and show placement performance in the admin panel without calling the Management API from public pages.
This commit is contained in:
2026-09-20 14:49:48 +02:00
parent 04a60a981e
commit ce0f278bac
30 changed files with 5098 additions and 12 deletions
+236
View File
@@ -0,0 +1,236 @@
# Skinbase AdSense Analytics Module
Status: Implemented
Project: Skinbase.org
Integration: Google AdSense Management API v2
Authorization: OAuth 2.0 Web Server Flow
Access level: Read-only (`adsense.readonly`)
This document is the implementation and operations guide for the admin AdSense analytics module. Public advertising behavior is unchanged: registered users remain ad-free; anonymous visitors may see AdSense units.
---
## 1. What the module does
Administrators can:
- connect Skinbase to Google AdSense with OAuth 2.0 (offline / refresh token);
- discover AdSense accounts, ad clients, ad units and custom channels;
- synchronize daily reporting into local MySQL tables;
- view an admin dashboard with totals, placement comparison, platform and country breakdowns;
- run hourly/nightly scheduled syncs or a manual **Sync now**;
- reconnect after Google revokes authorization.
No public page calls the AdSense Management API. The dashboard reads only local tables.
---
## 2. Google Cloud setup
1. Create or select a Google Cloud project.
2. Enable **Google AdSense Management API**.
3. Configure the OAuth consent screen (Internal or production publishing as required).
4. Create an OAuth Client ID of type **Web application**.
5. Add this exact authorized redirect URI:
```text
https://skinbase.org/admin/adsense/oauth/callback
```
6. Copy the client ID and client secret into the Skinbase environment (never into git).
7. Review Google OAuth publishing / testing limits before relying on unattended production refresh tokens.
---
## 3. Environment
Add to the deployment environment (see `.env.example`):
```env
ADSENSE_CLIENT_ID=
ADSENSE_CLIENT_SECRET=
ADSENSE_REDIRECT_URI=https://skinbase.org/admin/adsense/oauth/callback
ADSENSE_SYNC_ENABLED=true
```
Configuration lives in `config/adsense.php`. Do not store the Google client secret in the database.
---
## 4. Deploy commands
```bash
php artisan migrate --force
php artisan config:clear
php artisan route:clear
php artisan view:clear
```
Scheduler already loads `routes/console.php`. Ensure the production scheduler / cron is running (`php artisan schedule:run` every minute). Horizon / Redis queues must be running so `SyncAdsenseJob` can process manual and post-OAuth synchronizations.
---
## 5. Connect from Skinbase admin
1. Sign in as an **admin** (not manager/editorial).
2. Open **AdSense Analytics** (`/admin/adsense`) from the admin sidebar.
3. Click **Connect Google AdSense**.
4. Approve **read-only** AdSense access.
5. If multiple AdSense accounts exist, select one.
6. Initial sync imports entities and the previous 30 days.
7. Confirm the dashboard shows the account and last synchronization time.
Reconnect uses `prompt=consent` so Google issues a new refresh token. Token refresh never sends `prompt=consent`.
---
## 6. Artisan
```bash
php artisan adsense:sync
php artisan adsense:sync --days=3
php artisan adsense:sync --days=30
php artisan adsense:sync --from=2026-09-01 --to=2026-09-20
php artisan adsense:sync --entities-only
php artisan adsense:sync --force
```
`--force` is required when `ADSENSE_SYNC_ENABLED=false`. `--days` cannot be combined with `--from`/`--to`.
The command never prints tokens.
---
## 7. Scheduler
Registered in `routes/console.php`:
| Cadence | Command | Why |
| --- | --- | --- |
| Hourly at :08 | `adsense:sync --days=3` | Recent totals, including today, can still change |
| Nightly at 04:35 | `adsense:sync --days=30` | Late adjustments to the last month |
Both use `withoutOverlapping()` and `runInBackground()`. The project does not use `onOneServer()`.
---
## 8. Schema
### `adsense_connections`
Encrypted refresh token (`encrypted` Eloquent cast), status (`pending`, `connected`, `reconnect_required`, `error`, `disconnected`), selected account, sync timestamps, last error. Access tokens are cached, not stored permanently.
### `adsense_entities`
Upserted metadata for `ad_unit` and `custom_channel`. Entities are not deleted if they disappear from a single API page.
Inventory sync uses AdSense for Content (`AFC` / `ca-pub-...`) clients. YouTube host clients (`ca-yt-host-pub-...`) are listed by Google but do not expose ad units; they are skipped. A 404 on a single ad client does not fail the rest of the synchronization.
### `adsense_daily_stats`
Idempotent daily rows keyed by `account_resource_name + date + dimension_type + dimension_key`.
Dimension types: `total`, `ad_unit`, `custom_channel`, `platform`, `country`.
Monetary columns are `DECIMAL`, not floats.
Disconnect removes credentials and cached access tokens. Historical statistics are retained.
---
## 9. Reports
Separate report definitions against `GET /v2/{account}/reports:generate`:
| Report | Dimensions |
| --- | --- |
| A total | `DATE` |
| B ad unit | `DATE`, `AD_UNIT_ID` |
| C custom channel | `DATE`, `CUSTOM_CHANNEL_ID` |
| D platform | `DATE`, `PLATFORM_TYPE_CODE` |
| E country | `DATE`, `COUNTRY_CODE` |
The parser maps cells by header **name**, not position, and reads currency from `METRIC_CURRENCY` headers.
Country (and any other isolated report) can fail without rolling back successful reports.
---
## 10. Public advertising (unchanged)
- Guests may see AdSense (`AdSenseUnit` / `<x-ad-unit>` / homepage `ins.adsbygoogle`).
- Authenticated users do not receive those units.
- Artwork placement remains below Tags & Categories and before Comments.
Recommended Google AdSense unit names (rename inside Google AdSense; Skinbase is read-only):
```text
Skinbase_Artwork_BeforeComments
Skinbase_Home_AfterTrending
Skinbase_Home_AfterFresh
Skinbase_Browse_InFeed
Skinbase_Search_InFeed
Skinbase_News_InArticle
```
Current known slots:
| Surface | Slot | Recommended name |
| --- | --- | --- |
| Artwork before comments | `4398418697` | `Skinbase_Artwork_BeforeComments` |
| Homepage after trending (guest) | `8766048277` | `Skinbase_Home_AfterTrending` |
---
## 11. Security
- OAuth routes: `auth` + `admin.role`.
- Cryptographic `state` stored in the admin session and verified on callback.
- Authorization code exchange is server-side only.
- Refresh tokens encrypted at rest; access tokens cached.
- Tokens, authorization codes and client secrets are never sent to Inertia/JS and must not be logged.
- Scope is only `https://www.googleapis.com/auth/adsense.readonly`.
- Production redirect URI is HTTPS: `https://skinbase.org/admin/adsense/oauth/callback`.
---
## 12. Architecture
```text
App\Services\Adsense\AdsenseOAuthService
App\Services\Adsense\AdsenseApiClient
App\Services\Adsense\AdsenseReportParser
App\Services\Adsense\AdsenseSyncService
App\Services\Adsense\AdsenseAnalyticsQueryService
App\Http\Controllers\Admin\AdsenseAnalyticsController
App\Jobs\SyncAdsenseJob
App\Console\Commands\AdsenseSyncCommand
```
HTTP uses Laravel's HTTP client. The Google PHP SDK is not required.
---
## 13. Routes
| Method | Path | Name |
| --- | --- | --- |
| GET | `/admin/adsense` | `admin.adsense.index` |
| GET | `/admin/adsense/connect` | `admin.adsense.connect` |
| GET | `/admin/adsense/oauth/callback` | `admin.adsense.callback` |
| POST | `/admin/adsense/account` | `admin.adsense.account` |
| POST | `/admin/adsense/sync` | `admin.adsense.sync` |
| DELETE | `/admin/adsense/disconnect` | `admin.adsense.disconnect` |
These are registered **before** the legacy `/admin/{path}` → `/moderation/{path}` redirect so the Google redirect URI stays exact.
---
## 14. Tests
```bash
php artisan test --testsuite=Unit --filter=Adsense
php artisan test tests/Feature/Adsense
```
All Google HTTP is faked. Tests never require live Google credentials.
File diff suppressed because it is too large Load Diff