Upload beautify
This commit is contained in:
49
.copilot/categories-analysis.md
Normal file
49
.copilot/categories-analysis.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
Current DB & Models Analysis — 2026-02-10
|
||||||
|
|
||||||
|
Summary
|
||||||
|
- `content_types` is the master namespace (see screenshot). Rows: e.g. id=1 Photography (slug `photography`), id=2 Wallpapers, id=3 Skins, id=544 Members.
|
||||||
|
- `categories` references `content_types` through `content_type_id`; hierarchical parent/child relation via `parent_id`.
|
||||||
|
|
||||||
|
Observed DB columns (categories)
|
||||||
|
- id, content_type_id, parent_id, name, slug, description, image, is_active, sort_order, created_at, updated_at, deleted_at
|
||||||
|
|
||||||
|
Models verified
|
||||||
|
- `ContentType` (app/Models/ContentType.php)
|
||||||
|
- hasMany `categories()` and `rootCategories()`
|
||||||
|
- uses `slug` for route binding
|
||||||
|
- Status: OK and aligns with DB
|
||||||
|
|
||||||
|
- `Category` (app/Models/Category.php)
|
||||||
|
- belongsTo `contentType()`
|
||||||
|
- self-referential `parent()` / `children()` (ordered by `sort_order`, then `name`)
|
||||||
|
- `descendants()` recursive helper
|
||||||
|
- `seo()` relation, `artworks()` pivot
|
||||||
|
- scopes: `active()`, `roots()`
|
||||||
|
- accessors: `full_slug_path`, `url`, `canonical_url`, `breadcrumbs`
|
||||||
|
- slug validation enforced in `boot()` (lowercase; only a-z0-9- and dashes)
|
||||||
|
- Status: OK and consistent with screenshots
|
||||||
|
|
||||||
|
Key behaviors and checks
|
||||||
|
- URL formation: `$category->url` -> `/{content_type.slug}/{category_path}`; canonical URL -> `https://skinbase.org{url}`
|
||||||
|
- Slug policy: generation with `Str::slug()` + model validation. Do not bypass.
|
||||||
|
- Ordering: use `children()` (sort_order then name) for deterministic menus.
|
||||||
|
- Soft deletes: model uses `SoftDeletes`; be explicit when you need trashed categories.
|
||||||
|
- Eager-loading: `full_slug_path` walks parents — eager-load `parent` (or ancestors) to avoid N+1 when computing multiple paths.
|
||||||
|
|
||||||
|
Copilot / Dev rules (short checklist)
|
||||||
|
- Always look up content types by `slug`, not by numeric ID.
|
||||||
|
- Use `->roots()->active()->with('children')` for public category lists.
|
||||||
|
- Use `$category->url` and `$category->canonical_url` for links and canonical tags.
|
||||||
|
- Maintain slug rules: lowercase, only `a-z0-9-`.
|
||||||
|
- When reparenting categories, consider invalidating any cached derived paths for descendants.
|
||||||
|
- Avoid using legacy `artworks_categories` directly in new controllers; create an adapter if you must read old data.
|
||||||
|
|
||||||
|
Suggested next steps
|
||||||
|
- Add a PHPUnit test asserting slug validation and `url` generation for nested categories.
|
||||||
|
- (Optional) Generate a small ER diagram showing `content_types -> categories -> artwork_category`.
|
||||||
|
|
||||||
|
Files referenced
|
||||||
|
- [app/Models/ContentType.php](app/Models/ContentType.php)
|
||||||
|
- [app/Models/Category.php](app/Models/Category.php)
|
||||||
|
|
||||||
|
If you want, I can now add the PHPUnit test or generate the ER diagram.
|
||||||
144
.env.example
144
.env.example
@@ -45,6 +45,150 @@ BROADCAST_CONNECTION=log
|
|||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
# Upload UI feature flag (legacy upload remains default unless explicitly enabled)
|
||||||
|
SKINBASE_UPLOADS_V2=false
|
||||||
|
|
||||||
|
# Draft abuse prevention controls
|
||||||
|
SKINBASE_MAX_DRAFTS=10
|
||||||
|
SKINBASE_MAX_DRAFT_STORAGE_MB=1024
|
||||||
|
SKINBASE_DUPLICATE_HASH_POLICY=block
|
||||||
|
|
||||||
|
# Vision / AI auto-tagging (local defaults)
|
||||||
|
VISION_ENABLED=true
|
||||||
|
VISION_QUEUE=default
|
||||||
|
VISION_IMAGE_VARIANT=md
|
||||||
|
|
||||||
|
# CLIP service (set base URL to enable CLIP calls)
|
||||||
|
CLIP_BASE_URL=
|
||||||
|
CLIP_ANALYZE_ENDPOINT=/analyze
|
||||||
|
CLIP_TIMEOUT_SECONDS=8
|
||||||
|
CLIP_CONNECT_TIMEOUT_SECONDS=2
|
||||||
|
CLIP_HTTP_RETRIES=1
|
||||||
|
CLIP_HTTP_RETRY_DELAY_MS=200
|
||||||
|
CLIP_EMBED_ENDPOINT=/embed
|
||||||
|
CLIP_EMBED_TIMEOUT_SECONDS=8
|
||||||
|
CLIP_EMBED_CONNECT_TIMEOUT_SECONDS=2
|
||||||
|
CLIP_EMBED_HTTP_RETRIES=1
|
||||||
|
CLIP_EMBED_HTTP_RETRY_DELAY_MS=200
|
||||||
|
|
||||||
|
# Similar artworks / embedding pipeline
|
||||||
|
RECOMMENDATIONS_QUEUE=${VISION_QUEUE}
|
||||||
|
RECOMMENDATIONS_EMBEDDING_ENABLED=true
|
||||||
|
RECOMMENDATIONS_EMBEDDING_MODEL=clip
|
||||||
|
RECOMMENDATIONS_EMBEDDING_MODEL_VERSION=v1
|
||||||
|
RECOMMENDATIONS_ALGO_VERSION=clip-cosine-v1
|
||||||
|
RECOMMENDATIONS_AB_ALGO_VERSIONS=clip-cosine-v1
|
||||||
|
RECOMMENDATIONS_MIN_DIM=64
|
||||||
|
RECOMMENDATIONS_MAX_DIM=4096
|
||||||
|
RECOMMENDATIONS_BACKFILL_BATCH=200
|
||||||
|
|
||||||
|
# Personalized discovery foundation (Phase 8)
|
||||||
|
DISCOVERY_QUEUE=${RECOMMENDATIONS_QUEUE}
|
||||||
|
DISCOVERY_PROFILE_VERSION=profile-v1
|
||||||
|
DISCOVERY_EVENT_VERSION=event-v1
|
||||||
|
DISCOVERY_ALGO_VERSION=${RECOMMENDATIONS_ALGO_VERSION}
|
||||||
|
DISCOVERY_CACHE_VERSION=cache-v1
|
||||||
|
DISCOVERY_DECAY_HALF_LIFE_HOURS=72
|
||||||
|
DISCOVERY_WEIGHT_VIEW=1
|
||||||
|
DISCOVERY_WEIGHT_CLICK=2
|
||||||
|
DISCOVERY_WEIGHT_FAVORITE=4
|
||||||
|
DISCOVERY_WEIGHT_DOWNLOAD=3
|
||||||
|
DISCOVERY_CACHE_TTL_MINUTES=60
|
||||||
|
DISCOVERY_RANKING_WEIGHTS_VERSION=rank-w-v1
|
||||||
|
DISCOVERY_RANKING_W1=0.65
|
||||||
|
DISCOVERY_RANKING_W2=0.20
|
||||||
|
DISCOVERY_RANKING_W3=0.10
|
||||||
|
DISCOVERY_RANKING_W4=0.05
|
||||||
|
DISCOVERY_RANKING_WEIGHTS_VERSION_CLIP_COSINE_V1=rank-w-v1
|
||||||
|
DISCOVERY_RANKING_W1_CLIP_COSINE_V1=0.65
|
||||||
|
DISCOVERY_RANKING_W2_CLIP_COSINE_V1=0.20
|
||||||
|
DISCOVERY_RANKING_W3_CLIP_COSINE_V1=0.10
|
||||||
|
DISCOVERY_RANKING_W4_CLIP_COSINE_V1=0.05
|
||||||
|
DISCOVERY_RANKING_WEIGHTS_VERSION_CLIP_COSINE_V2=rank-w-v2-prod-1
|
||||||
|
DISCOVERY_RANKING_W1_CLIP_COSINE_V2=0.52
|
||||||
|
DISCOVERY_RANKING_W2_CLIP_COSINE_V2=0.23
|
||||||
|
DISCOVERY_RANKING_W3_CLIP_COSINE_V2=0.15
|
||||||
|
DISCOVERY_RANKING_W4_CLIP_COSINE_V2=0.10
|
||||||
|
DISCOVERY_ROLLOUT_ENABLED=false
|
||||||
|
DISCOVERY_ROLLOUT_BASELINE_ALGO_VERSION=clip-cosine-v1
|
||||||
|
DISCOVERY_ROLLOUT_CANDIDATE_ALGO_VERSION=clip-cosine-v2
|
||||||
|
DISCOVERY_ROLLOUT_ACTIVE_GATE=g10
|
||||||
|
DISCOVERY_ROLLOUT_GATE_10_PERCENT=10
|
||||||
|
DISCOVERY_ROLLOUT_GATE_50_PERCENT=50
|
||||||
|
DISCOVERY_ROLLOUT_GATE_100_PERCENT=100
|
||||||
|
DISCOVERY_FORCE_ALGO_VERSION=
|
||||||
|
DISCOVERY_ROLLOUT_WARN_CTR_DROP_PCT=3
|
||||||
|
DISCOVERY_ROLLOUT_ROLLBACK_CTR_DROP_PCT=5
|
||||||
|
DISCOVERY_ROLLOUT_WARN_LONG_DWELL_DROP_PCT=4
|
||||||
|
DISCOVERY_ROLLOUT_ROLLBACK_LONG_DWELL_DROP_PCT=8
|
||||||
|
DISCOVERY_ROLLOUT_WARN_DIVERSITY_CONCENTRATION_RISE_PCT=10
|
||||||
|
DISCOVERY_ROLLOUT_ROLLBACK_DIVERSITY_CONCENTRATION_RISE_PCT=15
|
||||||
|
DISCOVERY_EVAL_WEIGHT_CTR=0.45
|
||||||
|
DISCOVERY_EVAL_WEIGHT_SAVE_RATE=0.35
|
||||||
|
DISCOVERY_EVAL_WEIGHT_LONG_DWELL=0.25
|
||||||
|
DISCOVERY_EVAL_WEIGHT_BOUNCE_PENALTY=0.15
|
||||||
|
DISCOVERY_EVAL_SAVE_RATE_INFORMATIONAL=true
|
||||||
|
|
||||||
|
# YOLO service (optional)
|
||||||
|
YOLO_ENABLED=true
|
||||||
|
YOLO_BASE_URL=
|
||||||
|
YOLO_ANALYZE_ENDPOINT=/analyze
|
||||||
|
YOLO_TIMEOUT_SECONDS=8
|
||||||
|
YOLO_CONNECT_TIMEOUT_SECONDS=2
|
||||||
|
YOLO_HTTP_RETRIES=1
|
||||||
|
YOLO_HTTP_RETRY_DELAY_MS=200
|
||||||
|
YOLO_PHOTOGRAPHY_ONLY=true
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Production examples (uncomment and adjust)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# VISION_ENABLED=true
|
||||||
|
# VISION_QUEUE=vision
|
||||||
|
# VISION_IMAGE_VARIANT=md
|
||||||
|
#
|
||||||
|
# CLIP_BASE_URL=https://clip.internal
|
||||||
|
# CLIP_ANALYZE_ENDPOINT=/analyze
|
||||||
|
# CLIP_TIMEOUT_SECONDS=5
|
||||||
|
# CLIP_CONNECT_TIMEOUT_SECONDS=1
|
||||||
|
# CLIP_HTTP_RETRIES=1
|
||||||
|
# CLIP_HTTP_RETRY_DELAY_MS=150
|
||||||
|
# CLIP_EMBED_ENDPOINT=/embed
|
||||||
|
# CLIP_EMBED_TIMEOUT_SECONDS=5
|
||||||
|
# CLIP_EMBED_CONNECT_TIMEOUT_SECONDS=1
|
||||||
|
# CLIP_EMBED_HTTP_RETRIES=1
|
||||||
|
# CLIP_EMBED_HTTP_RETRY_DELAY_MS=150
|
||||||
|
# RECOMMENDATIONS_QUEUE=vision
|
||||||
|
# RECOMMENDATIONS_EMBEDDING_ENABLED=true
|
||||||
|
# RECOMMENDATIONS_EMBEDDING_MODEL=clip
|
||||||
|
# RECOMMENDATIONS_EMBEDDING_MODEL_VERSION=v1
|
||||||
|
# RECOMMENDATIONS_ALGO_VERSION=clip-cosine-v1
|
||||||
|
# RECOMMENDATIONS_AB_ALGO_VERSIONS=clip-cosine-v1,clip-cosine-v2
|
||||||
|
# RECOMMENDATIONS_BACKFILL_BATCH=250
|
||||||
|
# DISCOVERY_QUEUE=vision
|
||||||
|
# DISCOVERY_PROFILE_VERSION=profile-v1
|
||||||
|
# DISCOVERY_EVENT_VERSION=event-v1
|
||||||
|
# DISCOVERY_ALGO_VERSION=clip-cosine-v1
|
||||||
|
# DISCOVERY_CACHE_VERSION=cache-v1
|
||||||
|
# DISCOVERY_DECAY_HALF_LIFE_HOURS=72
|
||||||
|
# DISCOVERY_WEIGHT_VIEW=1
|
||||||
|
# DISCOVERY_WEIGHT_CLICK=2
|
||||||
|
# DISCOVERY_WEIGHT_FAVORITE=4
|
||||||
|
# DISCOVERY_WEIGHT_DOWNLOAD=3
|
||||||
|
# DISCOVERY_RANKING_WEIGHTS_VERSION=rank-w-v1
|
||||||
|
# DISCOVERY_RANKING_W1=0.65
|
||||||
|
# DISCOVERY_RANKING_W2=0.20
|
||||||
|
# DISCOVERY_RANKING_W3=0.10
|
||||||
|
# DISCOVERY_RANKING_W4=0.05
|
||||||
|
#
|
||||||
|
# YOLO_ENABLED=true
|
||||||
|
# YOLO_BASE_URL=https://yolo.internal
|
||||||
|
# YOLO_ANALYZE_ENDPOINT=/analyze
|
||||||
|
# YOLO_TIMEOUT_SECONDS=5
|
||||||
|
# YOLO_CONNECT_TIMEOUT_SECONDS=1
|
||||||
|
# YOLO_HTTP_RETRIES=1
|
||||||
|
# YOLO_HTTP_RETRY_DELAY_MS=150
|
||||||
|
# YOLO_PHOTOGRAPHY_ONLY=true
|
||||||
|
|
||||||
CACHE_STORE=database
|
CACHE_STORE=database
|
||||||
# CACHE_PREFIX=
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
|
|||||||
366
README.md
366
README.md
@@ -54,6 +54,372 @@ In order to ensure that the Laravel community is welcoming to all, please review
|
|||||||
|
|
||||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||||
|
|
||||||
|
## Vision & AI Auto-Tagging Integration
|
||||||
|
|
||||||
|
## Upload UI Feature Flag (`uploads.v2`)
|
||||||
|
|
||||||
|
The new React upload wizard is behind a feature flag and is **disabled by default**.
|
||||||
|
|
||||||
|
- Flag env var: `SKINBASE_UPLOADS_V2`
|
||||||
|
- Config key: `features.uploads_v2`
|
||||||
|
- Client flags source: `window.SKINBASE_FLAGS`
|
||||||
|
|
||||||
|
### Default behavior
|
||||||
|
|
||||||
|
- `SKINBASE_UPLOADS_V2=false` → legacy upload UI is rendered.
|
||||||
|
- `SKINBASE_UPLOADS_V2=true` → `UploadWizard` is rendered.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
In `.env` (or `.env.example` for project defaults):
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
SKINBASE_UPLOADS_V2=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable explicitly when ready:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
SKINBASE_UPLOADS_V2=true
|
||||||
|
```
|
||||||
|
|
||||||
|
After changing env values, clear/reload config as usual:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan config:clear
|
||||||
|
```
|
||||||
|
|
||||||
|
The system intentionally keeps legacy upload as the default until the flag is explicitly turned on.
|
||||||
|
|
||||||
|
## Upload Moderation UI Flow
|
||||||
|
|
||||||
|
Admin moderation for draft uploads is available through a dedicated queue page.
|
||||||
|
|
||||||
|
- Page route: `/admin/uploads/moderation`
|
||||||
|
- Access: authenticated users with `role=admin` or `role=moderator`
|
||||||
|
- Data source: `GET /api/admin/uploads/pending`
|
||||||
|
|
||||||
|
### Queue behavior
|
||||||
|
|
||||||
|
1. The page loads pending draft uploads (`moderation_status=pending`).
|
||||||
|
2. Moderators can enter an optional note per upload.
|
||||||
|
3. Approve action calls:
|
||||||
|
|
||||||
|
- `POST /api/admin/uploads/{id}/approve`
|
||||||
|
- Sets moderation to approved and records moderator + timestamp.
|
||||||
|
|
||||||
|
4. Reject action calls:
|
||||||
|
|
||||||
|
- `POST /api/admin/uploads/{id}/reject`
|
||||||
|
- Sets upload status/processing state to rejected and stores note.
|
||||||
|
|
||||||
|
### Publish gate
|
||||||
|
|
||||||
|
- Normal users can publish only when `moderation_status=approved`.
|
||||||
|
- Admin users can publish with override behavior.
|
||||||
|
|
||||||
|
## Similar Artworks Analytics (A/B Evaluation)
|
||||||
|
|
||||||
|
The artwork page similar-items block emits two event types:
|
||||||
|
|
||||||
|
- `impression` (block rendered)
|
||||||
|
- `click` (item clicked)
|
||||||
|
|
||||||
|
Events are stored in `similar_artwork_events` and aggregated daily into `similar_artwork_daily_metrics` by `algo_version`.
|
||||||
|
|
||||||
|
- Ingest endpoint: `POST /api/analytics/similar-artworks`
|
||||||
|
- Aggregation command: `php artisan analytics:aggregate-similar-artworks --date=YYYY-MM-DD`
|
||||||
|
- Scheduler: runs daily at `03:10`
|
||||||
|
|
||||||
|
## Personalized Discovery Foundation (Phase 8)
|
||||||
|
|
||||||
|
This foundation adds versioned, async-only ingestion and profile normalization for personalized discovery.
|
||||||
|
|
||||||
|
- Tables:
|
||||||
|
- `user_interest_profiles`
|
||||||
|
- `user_discovery_events`
|
||||||
|
- `user_recommendation_cache`
|
||||||
|
- Ingest endpoint: `POST /api/discovery/events` (auth required)
|
||||||
|
- Supported event types: `view`, `click`, `favorite`, `download`
|
||||||
|
- Processing model: non-blocking queue job (`IngestUserDiscoveryEventJob`)
|
||||||
|
- Normalization: recency-decay + score normalization in `UserInterestProfileService`
|
||||||
|
|
||||||
|
No feed ranking/UI behavior is introduced in this foundation step.
|
||||||
|
|
||||||
|
### Feed Endpoint Skeleton
|
||||||
|
|
||||||
|
The backend now exposes a personalized feed API skeleton:
|
||||||
|
|
||||||
|
- Endpoint: `GET /api/v1/feed` (auth required)
|
||||||
|
- Query params:
|
||||||
|
- `limit` (1-50, default 24)
|
||||||
|
- `cursor` (opaque cursor token for pagination)
|
||||||
|
- `algo_version` (optional override)
|
||||||
|
- Response includes `data` items and `meta.next_cursor` for cursor pagination.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- Reads `user_recommendation_cache` by `user_id + algo_version`.
|
||||||
|
- On cache miss/stale, returns immediate fallback results and dispatches async regeneration job.
|
||||||
|
- Regeneration runs in queue (`RegenerateUserRecommendationCacheJob`) and writes refreshed cache.
|
||||||
|
- Includes cold-start fallback (`popular + similar`) and a diversity guard to avoid near-duplicates.
|
||||||
|
|
||||||
|
## Feed Analytics Instrumentation
|
||||||
|
|
||||||
|
Feed analytics now track:
|
||||||
|
|
||||||
|
- `feed_impression`
|
||||||
|
- `feed_click`
|
||||||
|
|
||||||
|
Payload dimensions:
|
||||||
|
|
||||||
|
- `user_id` (derived from auth session)
|
||||||
|
- `artwork_id`
|
||||||
|
- `position`
|
||||||
|
- `algo_version`
|
||||||
|
- `source` (`personalized`, `cold_start`, `fallback`)
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `dwell_seconds` (for click dwell bucket metrics)
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
|
||||||
|
- Ingest: `POST /api/analytics/feed` (auth required)
|
||||||
|
- Daily aggregation: `php artisan analytics:aggregate-feed --date=YYYY-MM-DD`
|
||||||
|
- Admin report: `GET /api/admin/reports/feed-performance`
|
||||||
|
|
||||||
|
Daily metrics include CTR, save-rate, and dwell buckets.
|
||||||
|
|
||||||
|
For non-blocking client transport, use `navigator.sendBeacon` with `fetch(..., { keepalive: true })` fallback.
|
||||||
|
Reference helper: `resources/js/lib/feedAnalytics.js`.
|
||||||
|
|
||||||
|
## Phase 8B: Ranking Weight Tuning (Manual + Data-Driven)
|
||||||
|
|
||||||
|
Discovery ranking now supports versioned blend weights per `algo_version` in `config/discovery.php`.
|
||||||
|
|
||||||
|
- Blend terms: `w1` interest, `w2` recency, `w3` popularity, `w4` novelty
|
||||||
|
- Per-algo sets: `discovery.ranking.algo_weight_sets`
|
||||||
|
- Safe rollout: deterministic traffic split by `algo_version` with config gates (`g10`, `g50`, `g100`)
|
||||||
|
- Emergency rollback: `DISCOVERY_FORCE_ALGO_VERSION=clip-cosine-v1`
|
||||||
|
|
||||||
|
Offline evaluator and A/B helper:
|
||||||
|
|
||||||
|
- Evaluate objective across one/all algos:
|
||||||
|
- `php artisan analytics:evaluate-feed-weights --from=YYYY-MM-DD --to=YYYY-MM-DD`
|
||||||
|
- Optional: `--algo=clip-cosine-v1`
|
||||||
|
- Baseline vs candidate comparison:
|
||||||
|
- `php artisan analytics:compare-feed-ab clip-cosine-v1 clip-cosine-v2 --from=YYYY-MM-DD --to=YYYY-MM-DD`
|
||||||
|
|
||||||
|
Objective score uses `feed_daily_metrics` and configurable objective weights in `discovery.evaluation.objective_weights`.
|
||||||
|
|
||||||
|
Temporary production policy: set `DISCOVERY_EVAL_SAVE_RATE_INFORMATIONAL=true` to keep `save_rate` visible but excluded from objective score until save-event ingestion is verified.
|
||||||
|
|
||||||
|
Operational runbook: `docs/feed-rollout-runbook.md`.
|
||||||
|
|
||||||
|
## Operations / Runbooks
|
||||||
|
|
||||||
|
- Upload UI v2 rollout, post-deploy monitoring, and rollback: `docs/ui/upload-v2-rollout-runbook.md`
|
||||||
|
- Feed rollout and rollback: `docs/feed-rollout-runbook.md`
|
||||||
|
|
||||||
|
No automatic tuning is enabled in this phase.
|
||||||
|
|
||||||
|
Skinbase uses asynchronous AI tagging via `AutoTagArtworkJob`.
|
||||||
|
The job calls external vision services (CLIP and optional YOLO), normalizes tags, and attaches them through `TagService` as AI tags with confidence values.
|
||||||
|
|
||||||
|
### Critical Safety Rule
|
||||||
|
|
||||||
|
⚠️ **Publish must never depend on vision services.**
|
||||||
|
|
||||||
|
- Upload/publish flow dispatches AI tagging to queue after publish work.
|
||||||
|
- Vision failures, timeouts, or service outages must not block artwork publish.
|
||||||
|
- If AI tagging fails, artwork remains published and can be tagged later (retry/manual/batch).
|
||||||
|
|
||||||
|
### Environment Variables (Vision)
|
||||||
|
|
||||||
|
Set these in `.env` (all are optional; defaults are in `config/vision.php`):
|
||||||
|
|
||||||
|
#### Global
|
||||||
|
|
||||||
|
- `VISION_ENABLED` (default: `true`)
|
||||||
|
- Master switch for all AI auto-tagging.
|
||||||
|
- `VISION_QUEUE` (default: `default`)
|
||||||
|
- Queue name used by `AutoTagArtworkJob`.
|
||||||
|
- `VISION_IMAGE_VARIANT` (default: `md`)
|
||||||
|
- Derivative variant sent to vision services (e.g. `md`, `lg`).
|
||||||
|
|
||||||
|
#### CLIP
|
||||||
|
|
||||||
|
- `CLIP_BASE_URL` (default: empty)
|
||||||
|
- Base URL for CLIP service (example: `https://clip.internal`).
|
||||||
|
- If empty, CLIP call is skipped.
|
||||||
|
- `CLIP_ANALYZE_ENDPOINT` (default: `/analyze`)
|
||||||
|
- Path appended to `CLIP_BASE_URL`.
|
||||||
|
- `CLIP_TIMEOUT_SECONDS` (default: `8`)
|
||||||
|
- Request timeout for CLIP calls.
|
||||||
|
- `CLIP_CONNECT_TIMEOUT_SECONDS` (default: `2`)
|
||||||
|
- Connection timeout for CLIP calls.
|
||||||
|
- `CLIP_HTTP_RETRIES` (default: `1`)
|
||||||
|
- HTTP retry attempts for CLIP requests.
|
||||||
|
- `CLIP_HTTP_RETRY_DELAY_MS` (default: `200`)
|
||||||
|
- Delay between CLIP retries.
|
||||||
|
|
||||||
|
#### YOLO (optional)
|
||||||
|
|
||||||
|
- `YOLO_ENABLED` (default: `true`)
|
||||||
|
- Enables YOLO integration.
|
||||||
|
- `YOLO_BASE_URL` (default: empty)
|
||||||
|
- Base URL for YOLO service. If empty, YOLO call is skipped.
|
||||||
|
- `YOLO_ANALYZE_ENDPOINT` (default: `/analyze`)
|
||||||
|
- Path appended to `YOLO_BASE_URL`.
|
||||||
|
- `YOLO_TIMEOUT_SECONDS` (default: `8`)
|
||||||
|
- Request timeout for YOLO calls.
|
||||||
|
- `YOLO_CONNECT_TIMEOUT_SECONDS` (default: `2`)
|
||||||
|
- Connection timeout for YOLO calls.
|
||||||
|
- `YOLO_HTTP_RETRIES` (default: `1`)
|
||||||
|
- HTTP retry attempts for YOLO requests.
|
||||||
|
- `YOLO_HTTP_RETRY_DELAY_MS` (default: `200`)
|
||||||
|
- Delay between YOLO retries.
|
||||||
|
- `YOLO_PHOTOGRAPHY_ONLY` (default: `true`)
|
||||||
|
- When `true`, YOLO is called only for artworks in photography content type.
|
||||||
|
|
||||||
|
### Expected CLIP Response Format
|
||||||
|
|
||||||
|
CLIP `/analyze` should return tags as either a direct list or under `tags` / `data`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "tag": "cyberpunk", "confidence": 0.42 },
|
||||||
|
{ "tag": "city", "confidence": 0.31 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Also accepted:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tags": [
|
||||||
|
{ "tag": "cyberpunk", "confidence": 0.42 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{ "tag": "cyberpunk", "confidence": 0.42 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expected YOLO Response Format
|
||||||
|
|
||||||
|
YOLO may return the same tag list format as CLIP, or object detections:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"objects": [
|
||||||
|
{ "label": "person", "confidence": 0.91 },
|
||||||
|
{ "label": "camera", "confidence": 0.67 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`label` values are converted to tags, confidence is preserved when present.
|
||||||
|
|
||||||
|
### AutoTagArtworkJob Behavior
|
||||||
|
|
||||||
|
- Calls CLIP `/analyze` when `VISION_ENABLED=true` and `CLIP_BASE_URL` is set.
|
||||||
|
- Optionally calls YOLO based on `YOLO_ENABLED` and `YOLO_PHOTOGRAPHY_ONLY`.
|
||||||
|
- Merges CLIP + YOLO tags and keeps highest confidence for duplicates.
|
||||||
|
- Normalizes tags before attach (lowercase, cleanup, slug-safe format).
|
||||||
|
- Uses `TagService::attachAiTags()` to store pivot data:
|
||||||
|
- `source = ai`
|
||||||
|
- `confidence = <float|null>`
|
||||||
|
- Runs with queue retry + timeout safety (`tries`, `backoff`, `timeout`).
|
||||||
|
- Logs failures with reference/context for troubleshooting.
|
||||||
|
- On non-retriable response scenarios (e.g. 4xx), job exits safely without blocking publish.
|
||||||
|
|
||||||
|
### Queue / Worker Requirements (`VISION_QUEUE`)
|
||||||
|
|
||||||
|
- Ensure a worker is running for the configured queue.
|
||||||
|
- Example worker command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan queue:work --queue=default
|
||||||
|
```
|
||||||
|
|
||||||
|
- If `VISION_QUEUE=vision`, run worker for that queue:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan queue:work --queue=vision
|
||||||
|
```
|
||||||
|
|
||||||
|
- In production, use Supervisor/systemd/Horizon to keep workers alive.
|
||||||
|
- Without an active worker, auto-tagging jobs remain queued and will not execute.
|
||||||
|
|
||||||
|
### Local vs Production Notes
|
||||||
|
|
||||||
|
#### Local development
|
||||||
|
|
||||||
|
- For fully offline local work, set `VISION_ENABLED=false`.
|
||||||
|
- Or set only `CLIP_BASE_URL`/`YOLO_BASE_URL` you can reach locally.
|
||||||
|
- Prefer short timeouts to avoid slow dev feedback loops.
|
||||||
|
|
||||||
|
#### Production
|
||||||
|
|
||||||
|
- Use internal/private service endpoints for CLIP/YOLO when possible.
|
||||||
|
- Keep conservative timeouts and low retry counts to prevent queue congestion.
|
||||||
|
- Monitor failed jobs and logs for vision service reliability.
|
||||||
|
- Scale queue workers based on upload volume and service latency.
|
||||||
|
|
||||||
|
### Verify Setup (Health + Test Call)
|
||||||
|
|
||||||
|
After configuring env vars and restarting workers, verify in this order:
|
||||||
|
|
||||||
|
Quick helper (PowerShell):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -File ./scripts/vision-smoke.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional flags:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -File ./scripts/vision-smoke.ps1 -EnvFile ".env" -SampleImageUrl "https://files.skinbase.org/img/aa/bb/cc/md.webp"
|
||||||
|
pwsh -File ./scripts/vision-smoke.ps1 -SkipAnalyze
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Confirm queue worker is consuming `VISION_QUEUE`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan queue:work --queue=default
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Check CLIP/YOLO health endpoints (replace host/port as needed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS "$CLIP_BASE_URL/health"
|
||||||
|
curl -fsS "$YOLO_BASE_URL/health"
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Make a direct analyze test call (CLIP example):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "$CLIP_BASE_URL$CLIP_ANALYZE_ENDPOINT" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"image_url":"https://files.skinbase.org/img/aa/bb/cc/md.webp"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Trigger an upload/publish and confirm:
|
||||||
|
|
||||||
|
- Publish response succeeds even if CLIP/YOLO is down.
|
||||||
|
- `AutoTagArtworkJob` is queued/executed asynchronously.
|
||||||
|
- AI tags appear on the artwork when services are healthy.
|
||||||
|
- Failures are logged, but publish is unaffected.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||||
|
|||||||
106
app/Console/Commands/AggregateFeedAnalyticsCommand.php
Normal file
106
app/Console/Commands/AggregateFeedAnalyticsCommand.php
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class AggregateFeedAnalyticsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'analytics:aggregate-feed {--date= : Date (Y-m-d), defaults to yesterday}';
|
||||||
|
|
||||||
|
protected $description = 'Aggregate feed analytics into daily metrics by algo version and source';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$date = $this->option('date')
|
||||||
|
? (string) $this->option('date')
|
||||||
|
: now()->subDay()->toDateString();
|
||||||
|
|
||||||
|
$rows = DB::table('feed_events')
|
||||||
|
->selectRaw('algo_version, source')
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'feed_impression' THEN 1 ELSE 0 END) AS impressions")
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' THEN 1 ELSE 0 END) AS clicks")
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds IS NOT NULL AND dwell_seconds < 5 THEN 1 ELSE 0 END) AS dwell_0_5")
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds >= 5 AND dwell_seconds < 30 THEN 1 ELSE 0 END) AS dwell_5_30")
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds >= 30 AND dwell_seconds < 120 THEN 1 ELSE 0 END) AS dwell_30_120")
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'feed_click' AND dwell_seconds >= 120 THEN 1 ELSE 0 END) AS dwell_120_plus")
|
||||||
|
->whereDate('event_date', $date)
|
||||||
|
->groupBy('algo_version', 'source')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$algoVersion = (string) $row->algo_version;
|
||||||
|
$source = (string) $row->source;
|
||||||
|
$impressions = (int) ($row->impressions ?? 0);
|
||||||
|
$clicks = (int) ($row->clicks ?? 0);
|
||||||
|
|
||||||
|
$saves = $this->countSavesForGroup($date, $algoVersion, $source);
|
||||||
|
|
||||||
|
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
|
||||||
|
$saveRate = $clicks > 0 ? $saves / $clicks : 0.0;
|
||||||
|
|
||||||
|
DB::table('feed_daily_metrics')->updateOrInsert(
|
||||||
|
[
|
||||||
|
'metric_date' => $date,
|
||||||
|
'algo_version' => $algoVersion,
|
||||||
|
'source' => $source,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'impressions' => $impressions,
|
||||||
|
'clicks' => $clicks,
|
||||||
|
'saves' => $saves,
|
||||||
|
'ctr' => $ctr,
|
||||||
|
'save_rate' => $saveRate,
|
||||||
|
'dwell_0_5' => (int) ($row->dwell_0_5 ?? 0),
|
||||||
|
'dwell_5_30' => (int) ($row->dwell_5_30 ?? 0),
|
||||||
|
'dwell_30_120' => (int) ($row->dwell_30_120 ?? 0),
|
||||||
|
'dwell_120_plus' => (int) ($row->dwell_120_plus ?? 0),
|
||||||
|
'updated_at' => now(),
|
||||||
|
'created_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Aggregated feed analytics for {$date}.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countSavesForGroup(string $date, string $algoVersion, string $source): int
|
||||||
|
{
|
||||||
|
/** @var Collection<int, object{user_id:int,artwork_id:int}> $clickedPairs */
|
||||||
|
$clickedPairs = DB::table('feed_events')
|
||||||
|
->select('user_id', 'artwork_id')
|
||||||
|
->whereDate('event_date', $date)
|
||||||
|
->where('event_type', 'feed_click')
|
||||||
|
->where('algo_version', $algoVersion)
|
||||||
|
->where('source', $source)
|
||||||
|
->groupBy('user_id', 'artwork_id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($clickedPairs->isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$saves = 0;
|
||||||
|
foreach ($clickedPairs as $pair) {
|
||||||
|
$hasSave = DB::table('user_discovery_events')
|
||||||
|
->whereDate('event_date', $date)
|
||||||
|
->where('user_id', (int) $pair->user_id)
|
||||||
|
->where('artwork_id', (int) $pair->artwork_id)
|
||||||
|
->where('algo_version', $algoVersion)
|
||||||
|
->whereIn('event_type', ['favorite', 'download'])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($hasSave) {
|
||||||
|
$saves++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $saves;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class AggregateSimilarArtworkAnalyticsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'analytics:aggregate-similar-artworks {--date= : Date (Y-m-d), defaults to yesterday}';
|
||||||
|
|
||||||
|
protected $description = 'Aggregate similar artwork analytics into daily counts by algo version';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$date = $this->option('date')
|
||||||
|
? (string) $this->option('date')
|
||||||
|
: now()->subDay()->toDateString();
|
||||||
|
|
||||||
|
$rows = DB::table('similar_artwork_events')
|
||||||
|
->selectRaw('algo_version')
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'impression' THEN 1 ELSE 0 END) AS impressions")
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
|
||||||
|
->whereDate('event_date', $date)
|
||||||
|
->groupBy('algo_version')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$impressions = (int) ($row->impressions ?? 0);
|
||||||
|
$clicks = (int) ($row->clicks ?? 0);
|
||||||
|
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
|
||||||
|
|
||||||
|
DB::table('similar_artwork_daily_metrics')->updateOrInsert(
|
||||||
|
[
|
||||||
|
'metric_date' => $date,
|
||||||
|
'algo_version' => (string) $row->algo_version,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'impressions' => $impressions,
|
||||||
|
'clicks' => $clicks,
|
||||||
|
'ctr' => $ctr,
|
||||||
|
'updated_at' => now(),
|
||||||
|
'created_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Aggregated similar artwork analytics for {$date}.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/Console/Commands/AvatarsMigrate.php
Normal file
83
app/Console/Commands/AvatarsMigrate.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use App\Services\AvatarService;
|
||||||
|
|
||||||
|
class AvatarsMigrate extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'avatars:migrate {--force}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Migrate legacy avatars to new WebP avatar storage';
|
||||||
|
|
||||||
|
protected $service;
|
||||||
|
|
||||||
|
public function __construct(AvatarService $service)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
$this->service = $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$this->info('Starting avatar migration...');
|
||||||
|
|
||||||
|
// Try to read legacy data from user_profiles.avatar_legacy or users.avatar_legacy or users.icon
|
||||||
|
$rows = DB::table('user_profiles')->select('user_id', 'avatar_legacy')->whereNotNull('avatar_legacy')->get();
|
||||||
|
|
||||||
|
if ($rows->isEmpty()) {
|
||||||
|
// fallback to users table
|
||||||
|
$rows = DB::table('users')->select('user_id', 'icon as avatar_legacy')->whereNotNull('icon')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$userId = $row->user_id;
|
||||||
|
$legacy = $row->avatar_legacy ?? null;
|
||||||
|
if (!$legacy) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try common legacy paths
|
||||||
|
$candidates = [
|
||||||
|
public_path('user-picture/' . $legacy),
|
||||||
|
public_path('avatar/' . $userId . '/' . $legacy),
|
||||||
|
storage_path('app/public/user-picture/' . $legacy),
|
||||||
|
storage_path('app/public/avatar/' . $userId . '/' . $legacy),
|
||||||
|
];
|
||||||
|
|
||||||
|
$found = false;
|
||||||
|
foreach ($candidates as $p) {
|
||||||
|
if (file_exists($p) && is_readable($p)) {
|
||||||
|
$this->info("Processing user {$userId} from {$p}");
|
||||||
|
$hash = $this->service->storeFromLegacyFile($userId, $p);
|
||||||
|
if ($hash) {
|
||||||
|
$this->info(" -> migrated, hash={$hash}");
|
||||||
|
$count++;
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$found) {
|
||||||
|
$this->warn("Legacy file not found for user {$userId}, filename={$legacy}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Migration complete. Processed: {$count}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Console/Commands/BackfillArtworkEmbeddingsCommand.php
Normal file
28
app/Console/Commands/BackfillArtworkEmbeddingsCommand.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Jobs\BackfillArtworkEmbeddingsJob;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
final class BackfillArtworkEmbeddingsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'artworks:embeddings-backfill {--after-id=0 : Resume after this artwork id} {--batch=200 : Batch size for resumable fan-out} {--force : Regenerate even when source hash matches}';
|
||||||
|
|
||||||
|
protected $description = 'Queue resumable CLIP embedding backfill for artworks';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$afterId = max(0, (int) $this->option('after-id'));
|
||||||
|
$batch = max(1, min((int) $this->option('batch'), 1000));
|
||||||
|
$force = (bool) $this->option('force');
|
||||||
|
|
||||||
|
BackfillArtworkEmbeddingsJob::dispatch($afterId, $batch, $force);
|
||||||
|
|
||||||
|
$this->info("Queued artwork embedding backfill (after_id={$afterId}, batch={$batch}, force=" . ($force ? 'yes' : 'no') . ').');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/Console/Commands/CompareFeedAbCommand.php
Normal file
71
app/Console/Commands/CompareFeedAbCommand.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Recommendations\FeedOfflineEvaluationService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
final class CompareFeedAbCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'analytics:compare-feed-ab
|
||||||
|
{baseline : Baseline algo_version}
|
||||||
|
{candidate : Candidate algo_version}
|
||||||
|
{--from= : Start date (Y-m-d), defaults to last 30 days}
|
||||||
|
{--to= : End date (Y-m-d), defaults to today}
|
||||||
|
{--json : Output as JSON}';
|
||||||
|
|
||||||
|
protected $description = 'A/B helper for baseline vs candidate feed algo comparison';
|
||||||
|
|
||||||
|
public function __construct(private readonly FeedOfflineEvaluationService $evaluator)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$from = (string) ($this->option('from') ?: now()->subDays(29)->toDateString());
|
||||||
|
$to = (string) ($this->option('to') ?: now()->toDateString());
|
||||||
|
|
||||||
|
if ($from > $to) {
|
||||||
|
$this->error('Invalid range: --from must be <= --to');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseline = (string) $this->argument('baseline');
|
||||||
|
$candidate = (string) $this->argument('candidate');
|
||||||
|
|
||||||
|
$comparison = $this->evaluator->compareBaselineCandidate($baseline, $candidate, $from, $to);
|
||||||
|
|
||||||
|
if ((bool) $this->option('json')) {
|
||||||
|
$this->line((string) json_encode($comparison, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['algo_version', 'ctr', 'save_rate', 'long_dwell_share', 'bounce_rate', 'objective_score'],
|
||||||
|
[[
|
||||||
|
(string) $comparison['baseline']['algo_version'],
|
||||||
|
(float) $comparison['baseline']['ctr'],
|
||||||
|
(float) $comparison['baseline']['save_rate'],
|
||||||
|
(float) $comparison['baseline']['long_dwell_share'],
|
||||||
|
(float) $comparison['baseline']['bounce_rate'],
|
||||||
|
(float) $comparison['baseline']['objective_score'],
|
||||||
|
], [
|
||||||
|
(string) $comparison['candidate']['algo_version'],
|
||||||
|
(float) $comparison['candidate']['ctr'],
|
||||||
|
(float) $comparison['candidate']['save_rate'],
|
||||||
|
(float) $comparison['candidate']['long_dwell_share'],
|
||||||
|
(float) $comparison['candidate']['bounce_rate'],
|
||||||
|
(float) $comparison['candidate']['objective_score'],
|
||||||
|
]]
|
||||||
|
);
|
||||||
|
|
||||||
|
$delta = (array) $comparison['delta'];
|
||||||
|
$this->line('Δ objective_score: ' . (string) $delta['objective_score']);
|
||||||
|
$this->line('Δ objective_lift_pct: ' . (string) ($delta['objective_lift_pct'] ?? 'n/a'));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/Console/Commands/EvaluateFeedWeightsCommand.php
Normal file
81
app/Console/Commands/EvaluateFeedWeightsCommand.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Recommendations\FeedOfflineEvaluationService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
final class EvaluateFeedWeightsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'analytics:evaluate-feed-weights
|
||||||
|
{--algo= : Optional algo_version to evaluate}
|
||||||
|
{--from= : Start date (Y-m-d), defaults to last 30 days}
|
||||||
|
{--to= : End date (Y-m-d), defaults to today}
|
||||||
|
{--json : Output as JSON}';
|
||||||
|
|
||||||
|
protected $description = 'Offline feed weight evaluation using feed_daily_metrics';
|
||||||
|
|
||||||
|
public function __construct(private readonly FeedOfflineEvaluationService $evaluator)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$from = (string) ($this->option('from') ?: now()->subDays(29)->toDateString());
|
||||||
|
$to = (string) ($this->option('to') ?: now()->toDateString());
|
||||||
|
$algo = $this->option('algo') ? (string) $this->option('algo') : null;
|
||||||
|
|
||||||
|
if ($from > $to) {
|
||||||
|
$this->error('Invalid range: --from must be <= --to');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($algo !== null && $algo !== '') {
|
||||||
|
$result = $this->evaluator->evaluateAlgo($algo, $from, $to);
|
||||||
|
|
||||||
|
if ((bool) $this->option('json')) {
|
||||||
|
$this->line((string) json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
} else {
|
||||||
|
$this->table(
|
||||||
|
['algo_version', 'ctr', 'save_rate', 'long_dwell_share', 'bounce_rate', 'objective_score'],
|
||||||
|
[[
|
||||||
|
(string) $result['algo_version'],
|
||||||
|
(float) $result['ctr'],
|
||||||
|
(float) $result['save_rate'],
|
||||||
|
(float) $result['long_dwell_share'],
|
||||||
|
(float) $result['bounce_rate'],
|
||||||
|
(float) $result['objective_score'],
|
||||||
|
]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = $this->evaluator->evaluateAll($from, $to);
|
||||||
|
|
||||||
|
if ((bool) $this->option('json')) {
|
||||||
|
$this->line((string) json_encode($results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = array_map(static fn (array $row): array => [
|
||||||
|
(string) $row['algo_version'],
|
||||||
|
(float) $row['ctr'],
|
||||||
|
(float) $row['save_rate'],
|
||||||
|
(float) $row['long_dwell_share'],
|
||||||
|
(float) $row['bounce_rate'],
|
||||||
|
(float) $row['objective_score'],
|
||||||
|
], $results);
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['algo_version', 'ctr', 'save_rate', 'long_dwell_share', 'bounce_rate', 'objective_score'],
|
||||||
|
$rows
|
||||||
|
);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ class ImportLegacyUsers extends Command
|
|||||||
|
|
||||||
DB::table('user_profiles')->insert([
|
DB::table('user_profiles')->insert([
|
||||||
'user_id' => $legacyId,
|
'user_id' => $legacyId,
|
||||||
'bio' => $row->about_me ?: $row->description ?: null,
|
'about' => $row->about_me ?: $row->description ?: null,
|
||||||
'avatar' => $row->picture ?: null,
|
'avatar' => $row->picture ?: null,
|
||||||
'cover_image' => $row->cover_art ?: null,
|
'cover_image' => $row->cover_art ?: null,
|
||||||
'country' => $row->country ?: null,
|
'country' => $row->country ?: null,
|
||||||
@@ -115,15 +115,7 @@ class ImportLegacyUsers extends Command
|
|||||||
'updated_at' => $now,
|
'updated_at' => $now,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!empty($row->web)) {
|
// Do not duplicate `website` into `user_social_links` — keep canonical site in `user_profiles.website`.
|
||||||
DB::table('user_social_links')->insert([
|
|
||||||
'user_id' => $legacyId,
|
|
||||||
'platform' => 'website',
|
|
||||||
'url' => $row->web,
|
|
||||||
'created_at' => $now,
|
|
||||||
'updated_at' => $now,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::table('user_statistics')->insert([
|
DB::table('user_statistics')->insert([
|
||||||
'user_id' => $legacyId,
|
'user_id' => $legacyId,
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
|||||||
use App\Console\Commands\ImportLegacyUsers;
|
use App\Console\Commands\ImportLegacyUsers;
|
||||||
use App\Console\Commands\ImportCategories;
|
use App\Console\Commands\ImportCategories;
|
||||||
use App\Console\Commands\MigrateFeaturedWorks;
|
use App\Console\Commands\MigrateFeaturedWorks;
|
||||||
|
use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
|
||||||
|
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
|
||||||
|
use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
||||||
|
use App\Console\Commands\EvaluateFeedWeightsCommand;
|
||||||
|
use App\Console\Commands\CompareFeedAbCommand;
|
||||||
|
use App\Uploads\Commands\CleanupUploadsCommand;
|
||||||
|
|
||||||
class Kernel extends ConsoleKernel
|
class Kernel extends ConsoleKernel
|
||||||
{
|
{
|
||||||
@@ -18,7 +24,14 @@ class Kernel extends ConsoleKernel
|
|||||||
ImportLegacyUsers::class,
|
ImportLegacyUsers::class,
|
||||||
ImportCategories::class,
|
ImportCategories::class,
|
||||||
MigrateFeaturedWorks::class,
|
MigrateFeaturedWorks::class,
|
||||||
|
\App\Console\Commands\AvatarsMigrate::class,
|
||||||
\App\Console\Commands\ResetAllUserPasswords::class,
|
\App\Console\Commands\ResetAllUserPasswords::class,
|
||||||
|
CleanupUploadsCommand::class,
|
||||||
|
BackfillArtworkEmbeddingsCommand::class,
|
||||||
|
AggregateSimilarArtworkAnalyticsCommand::class,
|
||||||
|
AggregateFeedAnalyticsCommand::class,
|
||||||
|
EvaluateFeedWeightsCommand::class,
|
||||||
|
CompareFeedAbCommand::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,7 +39,9 @@ class Kernel extends ConsoleKernel
|
|||||||
*/
|
*/
|
||||||
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
|
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
|
||||||
{
|
{
|
||||||
// $schedule->command('inspire')->hourly();
|
$schedule->command('uploads:cleanup')->dailyAt('03:00');
|
||||||
|
$schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
|
||||||
|
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
14
app/DTOs/Artworks/ArtworkDraftResult.php
Normal file
14
app/DTOs/Artworks/ArtworkDraftResult.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DTOs\Artworks;
|
||||||
|
|
||||||
|
final class ArtworkDraftResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $artworkId,
|
||||||
|
public readonly string $status
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/DTOs/Uploads/UploadChunkResult.php
Normal file
17
app/DTOs/Uploads/UploadChunkResult.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DTOs\Uploads;
|
||||||
|
|
||||||
|
final class UploadChunkResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $sessionId,
|
||||||
|
public readonly string $status,
|
||||||
|
public readonly int $receivedBytes,
|
||||||
|
public readonly int $totalBytes,
|
||||||
|
public readonly int $progress
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/DTOs/Uploads/UploadInitResult.php
Normal file
15
app/DTOs/Uploads/UploadInitResult.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DTOs\Uploads;
|
||||||
|
|
||||||
|
final class UploadInitResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $sessionId,
|
||||||
|
public readonly string $token,
|
||||||
|
public readonly string $status
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/DTOs/Uploads/UploadScanResult.php
Normal file
24
app/DTOs/Uploads/UploadScanResult.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DTOs\Uploads;
|
||||||
|
|
||||||
|
final class UploadScanResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly bool $ok,
|
||||||
|
public readonly string $reason
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function clean(): self
|
||||||
|
{
|
||||||
|
return new self(true, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function infected(string $reason): self
|
||||||
|
{
|
||||||
|
return new self(false, $reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/DTOs/Uploads/UploadSessionData.php
Normal file
22
app/DTOs/Uploads/UploadSessionData.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DTOs\Uploads;
|
||||||
|
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
|
final class UploadSessionData
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $id,
|
||||||
|
public readonly int $userId,
|
||||||
|
public readonly string $tempPath,
|
||||||
|
public readonly string $status,
|
||||||
|
public readonly string $ip,
|
||||||
|
public readonly CarbonImmutable $createdAt,
|
||||||
|
public readonly int $progress,
|
||||||
|
public readonly ?string $failureReason
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/DTOs/Uploads/UploadStoredFile.php
Normal file
23
app/DTOs/Uploads/UploadStoredFile.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DTOs\Uploads;
|
||||||
|
|
||||||
|
final class UploadStoredFile
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $path,
|
||||||
|
public readonly int $size,
|
||||||
|
public readonly string $extension
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromPath(string $path): self
|
||||||
|
{
|
||||||
|
$size = is_file($path) ? (int) filesize($path) : 0;
|
||||||
|
$extension = (string) pathinfo($path, PATHINFO_EXTENSION);
|
||||||
|
|
||||||
|
return new self($path, $size, $extension);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/DTOs/Uploads/UploadValidatedFile.php
Normal file
14
app/DTOs/Uploads/UploadValidatedFile.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DTOs\Uploads;
|
||||||
|
|
||||||
|
final class UploadValidatedFile
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly UploadValidationResult $validation,
|
||||||
|
public readonly ?string $hash
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/DTOs/Uploads/UploadValidationResult.php
Normal file
28
app/DTOs/Uploads/UploadValidationResult.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\DTOs\Uploads;
|
||||||
|
|
||||||
|
final class UploadValidationResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly bool $ok,
|
||||||
|
public readonly string $reason,
|
||||||
|
public readonly ?int $width,
|
||||||
|
public readonly ?int $height,
|
||||||
|
public readonly ?string $mime,
|
||||||
|
public readonly ?int $size
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ok(int $width, int $height, string $mime, int $size): self
|
||||||
|
{
|
||||||
|
return new self(true, '', $width, $height, $mime, $size);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fail(string $reason, ?int $width = null, ?int $height = null, ?string $mime = null, ?int $size = null): self
|
||||||
|
{
|
||||||
|
return new self(false, $reason, $width, $height, $mime, $size);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class FeedPerformanceReportController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'from' => ['nullable', 'date_format:Y-m-d'],
|
||||||
|
'to' => ['nullable', 'date_format:Y-m-d'],
|
||||||
|
'limit' => ['nullable', 'integer', 'min:1', 'max:1000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$from = (string) ($validated['from'] ?? now()->subDays(29)->toDateString());
|
||||||
|
$to = (string) ($validated['to'] ?? now()->toDateString());
|
||||||
|
$limit = (int) ($validated['limit'] ?? 100);
|
||||||
|
|
||||||
|
if ($from > $to) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Invalid date range: from must be before or equal to to.',
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = DB::table('feed_daily_metrics')
|
||||||
|
->selectRaw('algo_version, source')
|
||||||
|
->selectRaw('SUM(impressions) as impressions')
|
||||||
|
->selectRaw('SUM(clicks) as clicks')
|
||||||
|
->selectRaw('SUM(saves) as saves')
|
||||||
|
->selectRaw('SUM(dwell_0_5) as dwell_0_5')
|
||||||
|
->selectRaw('SUM(dwell_5_30) as dwell_5_30')
|
||||||
|
->selectRaw('SUM(dwell_30_120) as dwell_30_120')
|
||||||
|
->selectRaw('SUM(dwell_120_plus) as dwell_120_plus')
|
||||||
|
->whereBetween('metric_date', [$from, $to])
|
||||||
|
->groupBy('algo_version', 'source')
|
||||||
|
->orderBy('algo_version')
|
||||||
|
->orderBy('source')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$byAlgoSource = $rows->map(static function ($row): array {
|
||||||
|
$impressions = (int) ($row->impressions ?? 0);
|
||||||
|
$clicks = (int) ($row->clicks ?? 0);
|
||||||
|
$saves = (int) ($row->saves ?? 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'algo_version' => (string) $row->algo_version,
|
||||||
|
'source' => (string) $row->source,
|
||||||
|
'impressions' => $impressions,
|
||||||
|
'clicks' => $clicks,
|
||||||
|
'saves' => $saves,
|
||||||
|
'ctr' => round($impressions > 0 ? $clicks / $impressions : 0.0, 6),
|
||||||
|
'save_rate' => round($clicks > 0 ? $saves / $clicks : 0.0, 6),
|
||||||
|
'dwell_buckets' => [
|
||||||
|
'0_5' => (int) ($row->dwell_0_5 ?? 0),
|
||||||
|
'5_30' => (int) ($row->dwell_5_30 ?? 0),
|
||||||
|
'30_120' => (int) ($row->dwell_30_120 ?? 0),
|
||||||
|
'120_plus' => (int) ($row->dwell_120_plus ?? 0),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
})->values();
|
||||||
|
|
||||||
|
$topClickedArtworks = DB::table('feed_events as e')
|
||||||
|
->leftJoin('artworks as a', 'a.id', '=', 'e.artwork_id')
|
||||||
|
->selectRaw('e.algo_version')
|
||||||
|
->selectRaw('e.source')
|
||||||
|
->selectRaw('e.artwork_id')
|
||||||
|
->selectRaw('a.title as artwork_title')
|
||||||
|
->selectRaw("SUM(CASE WHEN e.event_type = 'feed_impression' THEN 1 ELSE 0 END) AS impressions")
|
||||||
|
->selectRaw("SUM(CASE WHEN e.event_type = 'feed_click' THEN 1 ELSE 0 END) AS clicks")
|
||||||
|
->whereBetween('e.event_date', [$from, $to])
|
||||||
|
->groupBy('e.algo_version', 'e.source', 'e.artwork_id', 'a.title')
|
||||||
|
->get()
|
||||||
|
->map(static function ($row): array {
|
||||||
|
$impressions = (int) ($row->impressions ?? 0);
|
||||||
|
$clicks = (int) ($row->clicks ?? 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'algo_version' => (string) $row->algo_version,
|
||||||
|
'source' => (string) $row->source,
|
||||||
|
'artwork_id' => (int) $row->artwork_id,
|
||||||
|
'artwork_title' => (string) ($row->artwork_title ?? ''),
|
||||||
|
'impressions' => $impressions,
|
||||||
|
'clicks' => $clicks,
|
||||||
|
'ctr' => round($impressions > 0 ? $clicks / $impressions : 0.0, 6),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->sort(static function (array $a, array $b): int {
|
||||||
|
$clickCompare = $b['clicks'] <=> $a['clicks'];
|
||||||
|
if ($clickCompare !== 0) {
|
||||||
|
return $clickCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $b['ctr'] <=> $a['ctr'];
|
||||||
|
})
|
||||||
|
->take($limit)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'meta' => [
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'generated_at' => now()->toISOString(),
|
||||||
|
'limit' => $limit,
|
||||||
|
],
|
||||||
|
'by_algo_source' => $byAlgoSource,
|
||||||
|
'top_clicked_artworks' => $topClickedArtworks,
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class SimilarArtworkReportController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'from' => ['nullable', 'date_format:Y-m-d'],
|
||||||
|
'to' => ['nullable', 'date_format:Y-m-d'],
|
||||||
|
'limit' => ['nullable', 'integer', 'min:1', 'max:1000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$from = (string) ($validated['from'] ?? now()->subDays(29)->toDateString());
|
||||||
|
$to = (string) ($validated['to'] ?? now()->toDateString());
|
||||||
|
$limit = (int) ($validated['limit'] ?? 100);
|
||||||
|
|
||||||
|
if ($from > $to) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Invalid date range: from must be before or equal to to.',
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
$byAlgoRows = DB::table('similar_artwork_events')
|
||||||
|
->selectRaw('algo_version')
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'impression' THEN 1 ELSE 0 END) AS impressions")
|
||||||
|
->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
|
||||||
|
->whereBetween('event_date', [$from, $to])
|
||||||
|
->groupBy('algo_version')
|
||||||
|
->orderBy('algo_version')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$byAlgo = $byAlgoRows->map(static function ($row): array {
|
||||||
|
$impressions = (int) ($row->impressions ?? 0);
|
||||||
|
$clicks = (int) ($row->clicks ?? 0);
|
||||||
|
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'algo_version' => (string) $row->algo_version,
|
||||||
|
'impressions' => $impressions,
|
||||||
|
'clicks' => $clicks,
|
||||||
|
'ctr' => round($ctr, 6),
|
||||||
|
];
|
||||||
|
})->values();
|
||||||
|
|
||||||
|
$pairRows = DB::table('similar_artwork_events as e')
|
||||||
|
->leftJoin('artworks as source', 'source.id', '=', 'e.source_artwork_id')
|
||||||
|
->leftJoin('artworks as similar', 'similar.id', '=', 'e.similar_artwork_id')
|
||||||
|
->selectRaw('e.algo_version')
|
||||||
|
->selectRaw('e.source_artwork_id')
|
||||||
|
->selectRaw('e.similar_artwork_id')
|
||||||
|
->selectRaw('source.title as source_title')
|
||||||
|
->selectRaw('similar.title as similar_title')
|
||||||
|
->selectRaw("SUM(CASE WHEN e.event_type = 'impression' THEN 1 ELSE 0 END) AS impressions")
|
||||||
|
->selectRaw("SUM(CASE WHEN e.event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
|
||||||
|
->whereBetween('e.event_date', [$from, $to])
|
||||||
|
->whereNotNull('e.similar_artwork_id')
|
||||||
|
->groupBy('e.algo_version', 'e.source_artwork_id', 'e.similar_artwork_id', 'source.title', 'similar.title')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$topSimilarities = $pairRows
|
||||||
|
->map(static function ($row): array {
|
||||||
|
$impressions = (int) ($row->impressions ?? 0);
|
||||||
|
$clicks = (int) ($row->clicks ?? 0);
|
||||||
|
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'algo_version' => (string) $row->algo_version,
|
||||||
|
'source_artwork_id' => (int) $row->source_artwork_id,
|
||||||
|
'source_title' => (string) ($row->source_title ?? ''),
|
||||||
|
'similar_artwork_id' => (int) $row->similar_artwork_id,
|
||||||
|
'similar_title' => (string) ($row->similar_title ?? ''),
|
||||||
|
'impressions' => $impressions,
|
||||||
|
'clicks' => $clicks,
|
||||||
|
'ctr' => round($ctr, 6),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->sort(function (array $a, array $b): int {
|
||||||
|
$ctrCompare = $b['ctr'] <=> $a['ctr'];
|
||||||
|
if ($ctrCompare !== 0) {
|
||||||
|
return $ctrCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
$clickCompare = $b['clicks'] <=> $a['clicks'];
|
||||||
|
if ($clickCompare !== 0) {
|
||||||
|
return $clickCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $b['impressions'] <=> $a['impressions'];
|
||||||
|
})
|
||||||
|
->take($limit)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'meta' => [
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'generated_at' => now()->toISOString(),
|
||||||
|
'limit' => $limit,
|
||||||
|
],
|
||||||
|
'by_algo_version' => $byAlgo,
|
||||||
|
'top_similarities' => $topSimilarities,
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Upload;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class UploadModerationController extends Controller
|
||||||
|
{
|
||||||
|
public function pending(): JsonResponse
|
||||||
|
{
|
||||||
|
$uploads = Upload::query()
|
||||||
|
->where('status', 'draft')
|
||||||
|
->where('moderation_status', 'pending')
|
||||||
|
->orderBy('created_at')
|
||||||
|
->get([
|
||||||
|
'id',
|
||||||
|
'user_id',
|
||||||
|
'type',
|
||||||
|
'status',
|
||||||
|
'processing_state',
|
||||||
|
'title',
|
||||||
|
'preview_path',
|
||||||
|
'created_at',
|
||||||
|
'moderation_status',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $uploads,
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approve(string $id, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$upload = Upload::query()->find($id);
|
||||||
|
|
||||||
|
if (! $upload) {
|
||||||
|
return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
$upload->moderation_status = 'approved';
|
||||||
|
$upload->moderated_at = now();
|
||||||
|
$upload->moderated_by = (int) $request->user()->id;
|
||||||
|
$upload->moderation_note = $request->input('note');
|
||||||
|
$upload->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'id' => (string) $upload->id,
|
||||||
|
'moderation_status' => (string) $upload->moderation_status,
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reject(string $id, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$upload = Upload::query()->find($id);
|
||||||
|
|
||||||
|
if (! $upload) {
|
||||||
|
return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
$upload->moderation_status = 'rejected';
|
||||||
|
$upload->status = 'rejected';
|
||||||
|
$upload->processing_state = 'rejected';
|
||||||
|
$upload->moderated_at = now();
|
||||||
|
$upload->moderated_by = (int) $request->user()->id;
|
||||||
|
$upload->moderation_note = (string) $request->input('note', '');
|
||||||
|
$upload->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'id' => (string) $upload->id,
|
||||||
|
'status' => (string) $upload->status,
|
||||||
|
'processing_state' => (string) $upload->processing_state,
|
||||||
|
'moderation_status' => (string) $upload->moderation_status,
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,14 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Artworks\ArtworkCreateRequest;
|
||||||
use App\Http\Resources\ArtworkListResource;
|
use App\Http\Resources\ArtworkListResource;
|
||||||
use App\Http\Resources\ArtworkResource;
|
use App\Http\Resources\ArtworkResource;
|
||||||
use App\Services\ArtworkService;
|
use App\Services\ArtworkService;
|
||||||
|
use App\Services\Artworks\ArtworkDraftService;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class ArtworkController extends Controller
|
class ArtworkController extends Controller
|
||||||
{
|
{
|
||||||
@@ -17,6 +20,27 @@ class ArtworkController extends Controller
|
|||||||
$this->service = $service;
|
$this->service = $service;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/artworks
|
||||||
|
* Creates a draft artwork placeholder for the upload pipeline.
|
||||||
|
*/
|
||||||
|
public function store(ArtworkCreateRequest $request, ArtworkDraftService $drafts)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
$result = $drafts->createDraft(
|
||||||
|
(int) $user->id,
|
||||||
|
(string) $data['title'],
|
||||||
|
isset($data['description']) ? (string) $data['description'] : null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'artwork_id' => $result->artworkId,
|
||||||
|
'status' => $result->status,
|
||||||
|
], Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/artworks/{slug}
|
* GET /api/v1/artworks/{slug}
|
||||||
* Returns a single public artwork resource by slug.
|
* Returns a single public artwork resource by slug.
|
||||||
|
|||||||
91
app/Http/Controllers/Api/ArtworkTagController.php
Normal file
91
app/Http/Controllers/Api/ArtworkTagController.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Artworks\ArtworkTagsStoreRequest;
|
||||||
|
use App\Http\Requests\Artworks\ArtworkTagsUpdateRequest;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\Tag;
|
||||||
|
use App\Services\TagService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class ArtworkTagController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TagService $tags,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(int $id, ArtworkTagsStoreRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$artwork = Artwork::query()->findOrFail($id);
|
||||||
|
$this->authorizeOrNotFound($request->user(), $artwork);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$payload = $request->validated();
|
||||||
|
$this->tags->attachUserTags($artwork, $payload['tags']);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true], Response::HTTP_CREATED);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$ref = (string) Str::uuid();
|
||||||
|
logger()->error('Artwork tag attach failed', ['ref' => $ref, 'artwork_id' => $artwork->id, 'user_id' => $request->user()?->id, 'exception' => $e]);
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Unable to update tags right now.',
|
||||||
|
'ref' => $ref,
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(int $id, ArtworkTagsUpdateRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$artwork = Artwork::query()->findOrFail($id);
|
||||||
|
$this->authorizeOrNotFound($request->user(), $artwork);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$payload = $request->validated();
|
||||||
|
$this->tags->syncTags($artwork, $payload['tags']);
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$ref = (string) Str::uuid();
|
||||||
|
logger()->error('Artwork tag sync failed', ['ref' => $ref, 'artwork_id' => $artwork->id, 'user_id' => $request->user()?->id, 'exception' => $e]);
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Unable to update tags right now.',
|
||||||
|
'ref' => $ref,
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(int $id, Tag $tag): JsonResponse
|
||||||
|
{
|
||||||
|
$artwork = Artwork::query()->findOrFail($id);
|
||||||
|
$this->authorizeOrNotFound(request()->user(), $artwork);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->tags->detachTags($artwork, [$tag->id]);
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$ref = (string) Str::uuid();
|
||||||
|
logger()->error('Artwork tag detach failed', ['ref' => $ref, 'artwork_id' => $artwork->id, 'tag_id' => $tag->id, 'user_id' => request()->user()?->id, 'exception' => $e]);
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Unable to update tags right now.',
|
||||||
|
'ref' => $ref,
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeOrNotFound($user, Artwork $artwork): void
|
||||||
|
{
|
||||||
|
if (! $user) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->can('updateTags', $artwork)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Http/Controllers/Api/DiscoveryEventController.php
Normal file
49
app/Http/Controllers/Api/DiscoveryEventController.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Jobs\IngestUserDiscoveryEventJob;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class DiscoveryEventController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validate([
|
||||||
|
'event_id' => ['nullable', 'uuid'],
|
||||||
|
'event_type' => ['required', 'string', 'in:view,click,favorite,download'],
|
||||||
|
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
|
||||||
|
'occurred_at' => ['nullable', 'date'],
|
||||||
|
'algo_version' => ['nullable', 'string', 'max:64'],
|
||||||
|
'meta' => ['nullable', 'array'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$eventId = (string) ($payload['event_id'] ?? (string) Str::uuid());
|
||||||
|
$algoVersion = (string) ($payload['algo_version'] ?? config('discovery.algo_version', 'clip-cosine-v1'));
|
||||||
|
$occurredAt = isset($payload['occurred_at'])
|
||||||
|
? (string) $payload['occurred_at']
|
||||||
|
: now()->toIso8601String();
|
||||||
|
|
||||||
|
IngestUserDiscoveryEventJob::dispatch(
|
||||||
|
eventId: $eventId,
|
||||||
|
userId: (int) $request->user()->id,
|
||||||
|
artworkId: (int) $payload['artwork_id'],
|
||||||
|
eventType: (string) $payload['event_type'],
|
||||||
|
algoVersion: $algoVersion,
|
||||||
|
occurredAt: $occurredAt,
|
||||||
|
meta: (array) ($payload['meta'] ?? [])
|
||||||
|
)->onQueue((string) config('discovery.queue', 'default'));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'queued' => true,
|
||||||
|
'event_id' => $eventId,
|
||||||
|
'algo_version' => $algoVersion,
|
||||||
|
], Response::HTTP_ACCEPTED);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Http/Controllers/Api/FeedAnalyticsController.php
Normal file
45
app/Http/Controllers/Api/FeedAnalyticsController.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class FeedAnalyticsController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validate([
|
||||||
|
'event_type' => ['required', 'string', 'in:feed_impression,feed_click'],
|
||||||
|
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
|
||||||
|
'position' => ['nullable', 'integer', 'min:1', 'max:500'],
|
||||||
|
'algo_version' => ['required', 'string', 'max:64'],
|
||||||
|
'source' => ['required', 'string', 'in:personalized,cold_start,fallback'],
|
||||||
|
'dwell_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'],
|
||||||
|
'occurred_at' => ['nullable', 'date'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$occurredAt = isset($payload['occurred_at']) ? now()->parse((string) $payload['occurred_at']) : now();
|
||||||
|
|
||||||
|
DB::table('feed_events')->insert([
|
||||||
|
'event_date' => $occurredAt->toDateString(),
|
||||||
|
'event_type' => (string) $payload['event_type'],
|
||||||
|
'user_id' => (int) $request->user()->id,
|
||||||
|
'artwork_id' => (int) $payload['artwork_id'],
|
||||||
|
'position' => isset($payload['position']) ? (int) $payload['position'] : null,
|
||||||
|
'algo_version' => (string) $payload['algo_version'],
|
||||||
|
'source' => (string) $payload['source'],
|
||||||
|
'dwell_seconds' => isset($payload['dwell_seconds']) ? (int) $payload['dwell_seconds'] : null,
|
||||||
|
'occurred_at' => $occurredAt,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['success' => true], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Http/Controllers/Api/FeedController.php
Normal file
35
app/Http/Controllers/Api/FeedController.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Recommendations\PersonalizedFeedService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class FeedController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PersonalizedFeedService $feedService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validate([
|
||||||
|
'limit' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||||
|
'cursor' => ['nullable', 'string', 'max:512'],
|
||||||
|
'algo_version' => ['nullable', 'string', 'max:64'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->feedService->getFeed(
|
||||||
|
userId: (int) $request->user()->id,
|
||||||
|
limit: isset($payload['limit']) ? (int) $payload['limit'] : 24,
|
||||||
|
cursor: isset($payload['cursor']) ? (string) $payload['cursor'] : null,
|
||||||
|
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class SimilarArtworkAnalyticsController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validate([
|
||||||
|
'event_type' => ['required', 'string', 'in:impression,click'],
|
||||||
|
'algo_version' => ['required', 'string', 'max:64'],
|
||||||
|
'source_artwork_id' => ['required', 'integer', 'exists:artworks,id'],
|
||||||
|
'similar_artwork_id' => ['nullable', 'integer', 'exists:artworks,id'],
|
||||||
|
'position' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||||
|
'items_count' => ['nullable', 'integer', 'min:0', 'max:100'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('similar_artwork_events')->insert([
|
||||||
|
'event_date' => now()->toDateString(),
|
||||||
|
'event_type' => (string) $payload['event_type'],
|
||||||
|
'algo_version' => (string) $payload['algo_version'],
|
||||||
|
'source_artwork_id' => (int) $payload['source_artwork_id'],
|
||||||
|
'similar_artwork_id' => isset($payload['similar_artwork_id']) ? (int) $payload['similar_artwork_id'] : null,
|
||||||
|
'position' => isset($payload['position']) ? (int) $payload['position'] : null,
|
||||||
|
'items_count' => isset($payload['items_count']) ? (int) $payload['items_count'] : null,
|
||||||
|
'occurred_at' => now(),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['success' => true], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/Http/Controllers/Api/TagController.php
Normal file
52
app/Http/Controllers/Api/TagController.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Tags\PopularTagsRequest;
|
||||||
|
use App\Http\Requests\Tags\TagSearchRequest;
|
||||||
|
use App\Models\Tag;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
final class TagController extends Controller
|
||||||
|
{
|
||||||
|
public function search(TagSearchRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$q = (string) ($request->validated()['q'] ?? '');
|
||||||
|
$q = trim($q);
|
||||||
|
|
||||||
|
$query = Tag::query()->where('is_active', true);
|
||||||
|
if ($q !== '') {
|
||||||
|
$query->where(function ($sub) use ($q): void {
|
||||||
|
$sub->where('name', 'like', $q . '%')
|
||||||
|
->orWhere('slug', 'like', $q . '%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$tags = $query
|
||||||
|
->orderByDesc('usage_count')
|
||||||
|
->limit(20)
|
||||||
|
->get(['id', 'name', 'slug', 'usage_count']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $tags,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function popular(PopularTagsRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$limit = (int) ($request->validated()['limit'] ?? 20);
|
||||||
|
|
||||||
|
$tags = Tag::query()
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderByDesc('usage_count')
|
||||||
|
->limit($limit)
|
||||||
|
->get(['id', 'name', 'slug', 'usage_count']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $tags,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
497
app/Http/Controllers/Api/UploadController.php
Normal file
497
app/Http/Controllers/Api/UploadController.php
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Uploads\UploadFinishRequest;
|
||||||
|
use App\Http\Requests\Uploads\UploadInitRequest;
|
||||||
|
use App\Http\Requests\Uploads\UploadChunkRequest;
|
||||||
|
use App\Http\Requests\Uploads\UploadCancelRequest;
|
||||||
|
use App\Http\Requests\Uploads\UploadStatusRequest;
|
||||||
|
use App\Jobs\GenerateDerivativesJob;
|
||||||
|
use App\Jobs\AutoTagArtworkJob;
|
||||||
|
use App\Jobs\GenerateArtworkEmbeddingJob;
|
||||||
|
use App\Repositories\Uploads\UploadSessionRepository;
|
||||||
|
use App\Services\Uploads\UploadChunkService;
|
||||||
|
use App\Services\Uploads\UploadCancelService;
|
||||||
|
use App\Services\Uploads\UploadAuditService;
|
||||||
|
use App\Services\Uploads\UploadPipelineService;
|
||||||
|
use App\Services\Uploads\UploadQuotaService;
|
||||||
|
use App\Services\Uploads\UploadSessionStatus;
|
||||||
|
use App\Services\Uploads\UploadStatusService;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Throwable;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use App\Uploads\Jobs\VirusScanJob;
|
||||||
|
use App\Uploads\Services\PublishService;
|
||||||
|
use App\Uploads\Exceptions\UploadNotFoundException;
|
||||||
|
use App\Uploads\Exceptions\UploadOwnershipException;
|
||||||
|
use App\Uploads\Exceptions\UploadPublishValidationException;
|
||||||
|
use App\Uploads\Services\ArchiveInspectorService;
|
||||||
|
use App\Uploads\Services\DraftQuotaService;
|
||||||
|
use App\Uploads\Exceptions\DraftQuotaException;
|
||||||
|
|
||||||
|
final class UploadController extends Controller
|
||||||
|
{
|
||||||
|
public function init(
|
||||||
|
UploadInitRequest $request,
|
||||||
|
UploadPipelineService $pipeline,
|
||||||
|
UploadQuotaService $quota,
|
||||||
|
UploadAuditService $audit
|
||||||
|
)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$quota->enforce($user->id);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
], Response::HTTP_TOO_MANY_REQUESTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $pipeline->initSession($user->id, (string) $request->ip());
|
||||||
|
|
||||||
|
$audit->log($user->id, 'upload_init_issued', (string) $request->ip(), [
|
||||||
|
'session_id' => $result->sessionId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'session_id' => $result->sessionId,
|
||||||
|
'upload_token' => $result->token,
|
||||||
|
'status' => $result->status,
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function finish(
|
||||||
|
UploadFinishRequest $request,
|
||||||
|
UploadPipelineService $pipeline,
|
||||||
|
UploadSessionRepository $sessions,
|
||||||
|
UploadAuditService $audit
|
||||||
|
) {
|
||||||
|
$user = $request->user();
|
||||||
|
$sessionId = (string) $request->validated('session_id');
|
||||||
|
$artworkId = (int) $request->validated('artwork_id');
|
||||||
|
|
||||||
|
$session = $sessions->getOrFail($sessionId);
|
||||||
|
|
||||||
|
$request->artwork();
|
||||||
|
|
||||||
|
$validated = $pipeline->validateAndHash($sessionId);
|
||||||
|
if (! $validated->validation->ok || ! $validated->hash) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Upload validation failed.',
|
||||||
|
'reason' => $validated->validation->reason,
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
$scan = $pipeline->scan($sessionId);
|
||||||
|
if (! $scan->ok) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Upload scan failed.',
|
||||||
|
'reason' => $scan->reason,
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$previewPath = null;
|
||||||
|
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, &$previewPath) {
|
||||||
|
if ((bool) config('uploads.queue_derivatives', false)) {
|
||||||
|
GenerateDerivativesJob::dispatch($sessionId, $validated->hash, $artworkId)->afterCommit();
|
||||||
|
return 'queued';
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $pipeline->processAndPublish($sessionId, $validated->hash, $artworkId);
|
||||||
|
$previewPath = $result['public']['md'] ?? $result['public']['lg'] ?? null;
|
||||||
|
|
||||||
|
// Derivatives are available now; dispatch AI auto-tagging.
|
||||||
|
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
|
||||||
|
GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit();
|
||||||
|
return UploadSessionStatus::PROCESSED;
|
||||||
|
});
|
||||||
|
|
||||||
|
$audit->log($user->id, 'upload_finished', $session->ip, [
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'hash' => $validated->hash,
|
||||||
|
'artwork_id' => $artworkId,
|
||||||
|
'status' => $status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'artwork_id' => $artworkId,
|
||||||
|
'status' => $status,
|
||||||
|
'preview_path' => $previewPath,
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('Upload finish failed', [
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'artwork_id' => $artworkId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Upload finish failed.',
|
||||||
|
], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function chunk(UploadChunkRequest $request, UploadChunkService $chunks)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$chunkFile = $request->file('chunk');
|
||||||
|
|
||||||
|
// Debug: log uploaded file object details to help diagnose missing chunk
|
||||||
|
try {
|
||||||
|
if (! $chunkFile) {
|
||||||
|
logger()->warning('Chunk upload: no file present on request', [
|
||||||
|
'session_id' => (string) $request->input('session_id'),
|
||||||
|
'headers' => $request->headers->all(),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
logger()->warning('Chunk upload file details', [
|
||||||
|
'session_id' => (string) $request->input('session_id'),
|
||||||
|
'client_name' => $chunkFile->getClientOriginalName() ?? null,
|
||||||
|
'client_size' => $chunkFile->getSize() ?? null,
|
||||||
|
'error' => $chunkFile->getError(),
|
||||||
|
'realpath' => $chunkFile->getRealPath(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
logger()->warning('Chunk upload debug logging failed', ['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use getPathname() — this returns the PHP temp filename even when
|
||||||
|
// getRealPath() may be false (platform/stream wrappers can cause
|
||||||
|
// getRealPath() to return false). getPathname() is safe for reading
|
||||||
|
// the uploaded chunk file.
|
||||||
|
$chunkPath = $chunkFile ? $chunkFile->getPathname() : '';
|
||||||
|
|
||||||
|
$result = $chunks->appendChunk(
|
||||||
|
(string) $request->input('session_id'),
|
||||||
|
(string) $chunkPath,
|
||||||
|
(int) $request->input('offset'),
|
||||||
|
(int) $request->input('chunk_size'),
|
||||||
|
(int) $request->input('total_size'),
|
||||||
|
(int) $user->id,
|
||||||
|
(string) $request->ip()
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'session_id' => $result->sessionId,
|
||||||
|
'status' => $result->status,
|
||||||
|
'received_bytes' => $result->receivedBytes,
|
||||||
|
'total_bytes' => $result->totalBytes,
|
||||||
|
'progress' => $result->progress,
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
logger()->warning('Upload chunk failed', [
|
||||||
|
'session_id' => (string) $request->input('session_id'),
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Include the underlying error message in the response during debugging
|
||||||
|
// so the frontend can show a useful description. Remove or hide this
|
||||||
|
// in production if you prefer more generic errors.
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Upload chunk failed.',
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function status(string $id, UploadStatusRequest $request, UploadStatusService $statusService, UploadAuditService $audit)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$payload = $statusService->get($id);
|
||||||
|
|
||||||
|
$audit->log($user->id, 'upload_status_checked', (string) $request->ip(), [
|
||||||
|
'session_id' => $id,
|
||||||
|
'status' => $payload['status'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'session_id' => $payload['session_id'],
|
||||||
|
'status' => $payload['status'],
|
||||||
|
'progress' => $payload['progress'],
|
||||||
|
'failure_reason' => $payload['failure_reason'],
|
||||||
|
'received_bytes' => $payload['received_bytes'] ?? 0,
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(UploadCancelRequest $request, UploadCancelService $cancel)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $cancel->cancel(
|
||||||
|
(string) $request->input('session_id'),
|
||||||
|
(int) $user->id,
|
||||||
|
(string) $request->ip()
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'session_id' => $result['session_id'],
|
||||||
|
'status' => $result['status'],
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
logger()->warning('Upload cancel failed', [
|
||||||
|
'session_id' => (string) $request->input('session_id'),
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Upload cancel failed.',
|
||||||
|
], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preload an upload draft: validate main file, create draft and store files.
|
||||||
|
*
|
||||||
|
* Returns JSON: { upload_id, status, expires_at }
|
||||||
|
*/
|
||||||
|
public function preload(Request $request, UploadDraftServiceInterface $draftService, ArchiveInspectorService $archiveInspector, DraftQuotaService $draftQuotaService)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'main' => ['required', 'file'],
|
||||||
|
'screenshots' => ['sometimes', 'array'],
|
||||||
|
'screenshots.*' => ['file', 'image', 'max:5120'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$main = $request->file('main');
|
||||||
|
|
||||||
|
// Detect type from mime
|
||||||
|
$mime = (string) $main->getClientMimeType();
|
||||||
|
$type = null;
|
||||||
|
if (str_starts_with($mime, 'image/')) {
|
||||||
|
$type = 'image';
|
||||||
|
} elseif (in_array($mime, ['application/zip', 'application/x-zip-compressed', 'application/x-tar', 'application/x-gzip', 'application/x-rar-compressed', 'application/octet-stream'])) {
|
||||||
|
$type = 'archive';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === null) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Invalid main file type.',
|
||||||
|
'errors' => [
|
||||||
|
'main' => ['The main file must be an image or archive.'],
|
||||||
|
],
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === 'archive') {
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'screenshots' => ['required', 'array', 'min:1'],
|
||||||
|
'screenshots.*' => ['file', 'image', 'max:5120'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'The given data was invalid.',
|
||||||
|
'errors' => $validator->errors(),
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
$inspection = $archiveInspector->inspect((string) $main->getPathname());
|
||||||
|
if (! $inspection->valid) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Archive inspection failed.',
|
||||||
|
'reason' => $inspection->reason,
|
||||||
|
'stats' => $inspection->stats,
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$incomingFiles = [$main];
|
||||||
|
if ($type === 'archive' && $request->hasFile('screenshots')) {
|
||||||
|
foreach ($request->file('screenshots') as $screenshot) {
|
||||||
|
$incomingFiles[] = $screenshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$mainHash = $draftService->calculateHash((string) $main->getPathname());
|
||||||
|
|
||||||
|
try {
|
||||||
|
$warnings = $draftQuotaService->assertCanCreateDraft($user, [
|
||||||
|
'files' => $incomingFiles,
|
||||||
|
'main_hash' => $mainHash,
|
||||||
|
]);
|
||||||
|
} catch (DraftQuotaException $e) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $e->machineCode(),
|
||||||
|
'code' => $e->machineCode(),
|
||||||
|
], $e->httpStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create draft record (meta-only) and store main file via service
|
||||||
|
$draft = $draftService->createDraft(['user_id' => $user->id, 'type' => $type]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$mainInfo = $draftService->storeMainFile($draft['id'], $main);
|
||||||
|
|
||||||
|
// If archive, allow optional screenshots to be uploaded in the same request
|
||||||
|
if ($type === 'archive' && $request->hasFile('screenshots')) {
|
||||||
|
foreach ($request->file('screenshots') as $ss) {
|
||||||
|
try {
|
||||||
|
$draftService->storeScreenshot($draft['id'], $ss);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// Keep controller thin: log and continue
|
||||||
|
logger()->warning('Screenshot store failed during preload', ['error' => $e->getMessage(), 'draft' => $draft['id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set expiration (default 7 days) and return info
|
||||||
|
$ttlDays = (int) config('uploads.draft_ttl_days', 7);
|
||||||
|
$expiresAt = Carbon::now()->addDays($ttlDays);
|
||||||
|
$draftService->setExpiration($draft['id'], $expiresAt);
|
||||||
|
|
||||||
|
VirusScanJob::dispatch($draft['id']);
|
||||||
|
|
||||||
|
$response = [
|
||||||
|
'upload_id' => $draft['id'],
|
||||||
|
'status' => 'draft',
|
||||||
|
'expires_at' => $expiresAt->toISOString(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! empty($warnings)) {
|
||||||
|
$response['warnings'] = array_values($warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($response, Response::HTTP_OK);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
logger()->error('Upload preload failed', ['error' => $e->getMessage()]);
|
||||||
|
return response()->json(['message' => 'Preload failed.'], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function autosave(string $id, Request $request)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$upload = DB::table('uploads')->where('id', $id)->first();
|
||||||
|
if (! $upload) {
|
||||||
|
return response()->json(['message' => 'Upload draft not found.'], Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $upload->user_id !== (int) $user->id) {
|
||||||
|
return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $upload->status !== 'draft') {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Only draft uploads can be autosaved.',
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'title' => ['nullable', 'string', 'max:255'],
|
||||||
|
'category_id' => ['nullable', 'exists:categories,id'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'tags' => ['nullable', 'array'],
|
||||||
|
'license' => ['nullable', 'string'],
|
||||||
|
'nsfw' => ['nullable', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$updates = [];
|
||||||
|
foreach (['title', 'category_id', 'description', 'tags', 'license', 'nsfw'] as $field) {
|
||||||
|
if (array_key_exists($field, $validated)) {
|
||||||
|
$updates[$field] = $validated[$field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$dirty = [];
|
||||||
|
foreach ($updates as $field => $value) {
|
||||||
|
$current = $upload->{$field} ?? null;
|
||||||
|
|
||||||
|
if ($field === 'tags') {
|
||||||
|
$current = $current ? json_decode((string) $current, true) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($field === 'nsfw') {
|
||||||
|
$current = is_null($current) ? null : (bool) $current;
|
||||||
|
$value = is_null($value) ? null : (bool) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($current !== $value) {
|
||||||
|
$dirty[$field] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('tags', $dirty)) {
|
||||||
|
$dirty['tags'] = json_encode($dirty['tags']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($dirty)) {
|
||||||
|
$dirty['updated_at'] = now();
|
||||||
|
DB::table('uploads')->where('id', $id)->update($dirty);
|
||||||
|
$upload = DB::table('uploads')->where('id', $id)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'updated_at' => (string) ($upload->updated_at ?? now()->toDateTimeString()),
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processingStatus(string $id, Request $request)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$upload = DB::table('uploads')->where('id', $id)->first();
|
||||||
|
if (! $upload) {
|
||||||
|
return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $upload->user_id !== (int) $user->id) {
|
||||||
|
return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (string) ($upload->status ?? 'draft');
|
||||||
|
$isScanned = (bool) ($upload->is_scanned ?? false);
|
||||||
|
$previewReady = ! empty($upload->preview_path);
|
||||||
|
$hasTags = (bool) ($upload->has_tags ?? false);
|
||||||
|
$processingState = (string) ($upload->processing_state ?? 'pending_scan');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'id' => (string) $upload->id,
|
||||||
|
'status' => $status,
|
||||||
|
'is_scanned' => $isScanned,
|
||||||
|
'preview_ready' => $previewReady,
|
||||||
|
'has_tags' => $hasTags,
|
||||||
|
'processing_state' => $processingState,
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publish(string $id, Request $request, PublishService $publishService)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$upload = $publishService->publish($id, $user);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'upload_id' => (string) $upload->id,
|
||||||
|
'status' => (string) $upload->status,
|
||||||
|
'published_at' => optional($upload->published_at)->toISOString(),
|
||||||
|
'final_path' => (string) $upload->final_path,
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
} catch (UploadOwnershipException $e) {
|
||||||
|
return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN);
|
||||||
|
} catch (UploadNotFoundException $e) {
|
||||||
|
return response()->json(['message' => $e->getMessage()], Response::HTTP_NOT_FOUND);
|
||||||
|
} catch (UploadPublishValidationException $e) {
|
||||||
|
return response()->json(['message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\CategoryPageController;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\ArtworkIndexRequest;
|
use App\Http\Requests\ArtworkIndexRequest;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
|
use App\Services\Recommendations\SimilarArtworksService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
@@ -51,14 +54,116 @@ class ArtworkController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a single artwork by slug. Ensure it's public, approved and not deleted.
|
* Show a single artwork by slug. Resolve the slug manually to avoid implicit
|
||||||
|
* route-model binding exceptions when the slug does not correspond to an artwork.
|
||||||
*/
|
*/
|
||||||
public function show(Artwork $artwork): View
|
public function show(Request $request, string $contentTypeSlug, string $categoryPath, $artwork = null)
|
||||||
{
|
{
|
||||||
if (! $artwork->is_public || ! $artwork->is_approved || $artwork->trashed()) {
|
// Manually resolve artwork by slug when provided. The route may bind
|
||||||
|
// the 'artwork' parameter to an Artwork model or pass the slug string.
|
||||||
|
$foundArtwork = null;
|
||||||
|
$artworkSlug = null;
|
||||||
|
if ($artwork instanceof Artwork) {
|
||||||
|
$foundArtwork = $artwork;
|
||||||
|
$artworkSlug = $artwork->slug;
|
||||||
|
} elseif ($artwork) {
|
||||||
|
$artworkSlug = (string) $artwork;
|
||||||
|
$foundArtwork = Artwork::where('slug', $artworkSlug)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no artwork was found, treat the request as a category path.
|
||||||
|
// The route places the artwork slug in the last segment, so include it
|
||||||
|
// when forwarding to CategoryPageController to support arbitrary-depth paths
|
||||||
|
if (! $foundArtwork) {
|
||||||
|
$combinedPath = $categoryPath;
|
||||||
|
if ($artworkSlug) {
|
||||||
|
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
|
||||||
|
}
|
||||||
|
return app(CategoryPageController::class)->show(request(), $contentTypeSlug, $combinedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $foundArtwork->is_public || ! $foundArtwork->is_approved || $foundArtwork->trashed()) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('artworks.show', ['artwork' => $artwork]);
|
$foundArtwork->loadMissing(['categories.contentType', 'user']);
|
||||||
|
|
||||||
|
$defaultAlgoVersion = (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1');
|
||||||
|
$selectedAlgoVersion = $this->selectAlgoVersionForRequest($request, $defaultAlgoVersion);
|
||||||
|
|
||||||
|
$similarService = app(SimilarArtworksService::class);
|
||||||
|
$similarArtworks = $similarService->forArtwork((int) $foundArtwork->id, 12, $selectedAlgoVersion);
|
||||||
|
|
||||||
|
if ($similarArtworks->isEmpty() && $selectedAlgoVersion !== $defaultAlgoVersion) {
|
||||||
|
$similarArtworks = $similarService->forArtwork((int) $foundArtwork->id, 12, $defaultAlgoVersion);
|
||||||
|
$selectedAlgoVersion = $defaultAlgoVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
$similarArtworks->each(static function (Artwork $item): void {
|
||||||
|
$item->loadMissing(['categories.contentType', 'user']);
|
||||||
|
});
|
||||||
|
|
||||||
|
$similarItems = $similarArtworks
|
||||||
|
->map(function (Artwork $item): ?array {
|
||||||
|
$category = $item->categories->first();
|
||||||
|
$contentType = $category?->contentType;
|
||||||
|
|
||||||
|
if (! $category || ! $contentType || empty($item->slug)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $item->id,
|
||||||
|
'title' => (string) $item->title,
|
||||||
|
'author' => (string) optional($item->user)->name,
|
||||||
|
'thumb' => (string) ($item->thumb_url ?? $item->thumb ?? '/gfx/sb_join.jpg'),
|
||||||
|
'thumb_srcset' => (string) ($item->thumb_srcset ?? ''),
|
||||||
|
'url' => route('artworks.show', [
|
||||||
|
'contentTypeSlug' => (string) $contentType->slug,
|
||||||
|
'categoryPath' => (string) $category->slug,
|
||||||
|
'artwork' => (string) $item->slug,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->filter()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return view('artworks.show', [
|
||||||
|
'artwork' => $foundArtwork,
|
||||||
|
'similarItems' => $similarItems,
|
||||||
|
'similarAlgoVersion' => $selectedAlgoVersion,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function selectAlgoVersionForRequest(Request $request, string $default): string
|
||||||
|
{
|
||||||
|
$configured = (array) config('recommendations.ab.algo_versions', []);
|
||||||
|
$versions = array_values(array_filter(array_map(static fn ($value): string => trim((string) $value), $configured)));
|
||||||
|
|
||||||
|
if ($versions === []) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($default, $versions, true)) {
|
||||||
|
array_unshift($versions, $default);
|
||||||
|
$versions = array_values(array_unique($versions));
|
||||||
|
}
|
||||||
|
|
||||||
|
$forced = trim((string) $request->query('algo_version', ''));
|
||||||
|
if ($forced !== '' && in_array($forced, $versions, true)) {
|
||||||
|
return $forced;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($versions) === 1) {
|
||||||
|
return $versions[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$visitorKey = $request->user()?->id
|
||||||
|
? 'u:' . (string) $request->user()->id
|
||||||
|
: 's:' . (string) $request->session()->getId();
|
||||||
|
|
||||||
|
$bucket = abs(crc32($visitorKey)) % count($versions);
|
||||||
|
|
||||||
|
return $versions[$bucket] ?? $default;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
app/Http/Controllers/AvatarController.php
Normal file
47
app/Http/Controllers/AvatarController.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Services\AvatarService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
|
class AvatarController
|
||||||
|
{
|
||||||
|
protected $service;
|
||||||
|
|
||||||
|
public function __construct(AvatarService $service)
|
||||||
|
{
|
||||||
|
$this->service = $service;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle avatar upload request.
|
||||||
|
*/
|
||||||
|
public function upload(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'avatar' => 'required|image|max:2048|mimes:jpg,jpeg,png,webp',
|
||||||
|
];
|
||||||
|
|
||||||
|
$validator = Validator::make($request->all(), $rules);
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json(['errors' => $validator->errors()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $request->file('avatar');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$hash = $this->service->storeFromUploadedFile($user->id, $file);
|
||||||
|
return response()->json(['success' => true, 'hash' => $hash], 200);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response()->json(['error' => 'Processing failed', 'message' => $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,13 @@ namespace App\Http\Controllers;
|
|||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\ContentType;
|
use App\Models\ContentType;
|
||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
|
use App\Services\ArtworkService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
class CategoryPageController extends Controller
|
class CategoryPageController extends Controller
|
||||||
{
|
{
|
||||||
public function show(Request $request, string $contentTypeSlug, string $categoryPath = null)
|
public function show(Request $request, string $contentTypeSlug, ?string $categoryPath = null)
|
||||||
{
|
{
|
||||||
$contentType = ContentType::where('slug', strtolower($contentTypeSlug))->first();
|
$contentType = ContentType::where('slug', strtolower($contentTypeSlug))->first();
|
||||||
if (! $contentType) {
|
if (! $contentType) {
|
||||||
@@ -24,38 +25,51 @@ class CategoryPageController extends Controller
|
|||||||
$page_title = $contentType->name;
|
$page_title = $contentType->name;
|
||||||
$page_meta_description = $contentType->description ?? ($contentType->name . ' artworks on Skinbase');
|
$page_meta_description = $contentType->description ?? ($contentType->name . ' artworks on Skinbase');
|
||||||
|
|
||||||
|
// Load artworks for this content type (show gallery on the root page)
|
||||||
|
$perPage = 40;
|
||||||
|
$artworks = Artwork::whereHas('categories', function ($q) use ($contentType) {
|
||||||
|
$q->where('categories.content_type_id', $contentType->id);
|
||||||
|
})
|
||||||
|
->published()->public()
|
||||||
|
->with([
|
||||||
|
'user:id,name',
|
||||||
|
'categories' => function ($q) {
|
||||||
|
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||||
|
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
|
||||||
|
},
|
||||||
|
])
|
||||||
|
->orderBy('published_at', 'desc')
|
||||||
|
->paginate($perPage)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
return view('legacy.content-type', compact(
|
return view('legacy.content-type', compact(
|
||||||
'contentType',
|
'contentType',
|
||||||
'rootCategories',
|
'rootCategories',
|
||||||
|
'artworks',
|
||||||
'page_title',
|
'page_title',
|
||||||
'page_meta_description'
|
'page_meta_description'
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
$segments = array_filter(explode('/', $categoryPath));
|
$segments = array_filter(explode('/', $categoryPath));
|
||||||
if (empty($segments)) {
|
$slugs = array_values(array_map('strtolower', $segments));
|
||||||
|
if (empty($slugs)) {
|
||||||
return redirect('/browse-categories');
|
return redirect('/browse-categories');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Traverse categories by slug path within the content type
|
// If the first slug exists but under a different content type, redirect to its canonical URL
|
||||||
$current = Category::where('content_type_id', $contentType->id)
|
$firstSlug = $slugs[0];
|
||||||
->whereNull('parent_id')
|
$globalRoot = Category::whereNull('parent_id')->where('slug', $firstSlug)->first();
|
||||||
->where('slug', strtolower(array_shift($segments)))
|
if ($globalRoot && $globalRoot->contentType && $globalRoot->contentType->slug !== strtolower($contentType->slug)) {
|
||||||
->first();
|
$redirectPath = '/' . $globalRoot->contentType->slug . '/' . implode('/', $slugs);
|
||||||
|
return redirect($redirectPath, 301);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve category by path using the helper that validates parent chain and content type
|
||||||
if (! $current) {
|
$category = Category::findByPath($contentType->slug, $slugs);
|
||||||
|
if (! $category) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($segments as $slug) {
|
|
||||||
$current = $current->children()->where('slug', strtolower($slug))->first();
|
|
||||||
if (! $current) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$category = $current;
|
|
||||||
$subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get();
|
$subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get();
|
||||||
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
|
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
|
||||||
|
|
||||||
@@ -71,21 +85,23 @@ class CategoryPageController extends Controller
|
|||||||
$category->load('children');
|
$category->load('children');
|
||||||
$gather($category);
|
$gather($category);
|
||||||
|
|
||||||
// Load artworks that are attached to any of these categories
|
// Load artworks via ArtworkService to support arbitrary-depth category paths
|
||||||
$query = Artwork::whereHas('categories', function ($q) use ($collected) {
|
|
||||||
$q->whereIn('categories.id', $collected);
|
|
||||||
})->published()->public();
|
|
||||||
|
|
||||||
// Paginate results
|
|
||||||
$perPage = 40;
|
$perPage = 40;
|
||||||
$artworks = $query->orderBy('published_at', 'desc')
|
try {
|
||||||
->paginate($perPage)
|
$service = app(ArtworkService::class);
|
||||||
->withQueryString();
|
// service expects an array with contentType slug first, then category slugs
|
||||||
|
$pathSlugs = array_merge([strtolower($contentTypeSlug)], $slugs);
|
||||||
|
$artworks = $service->getArtworksByCategoryPath($pathSlugs, $perPage);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
$page_title = $category->name;
|
$page_title = $category->name;
|
||||||
$page_meta_description = $category->description ?? ($contentType->name . ' artworks on Skinbase');
|
$page_meta_description = $category->description ?? ($contentType->name . ' artworks on Skinbase');
|
||||||
$page_meta_keywords = strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography';
|
$page_meta_keywords = strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography';
|
||||||
|
|
||||||
|
// resolved category and breadcrumbs are used by the view
|
||||||
|
|
||||||
return view('legacy.category-slug', compact(
|
return view('legacy.category-slug', compact(
|
||||||
'contentType',
|
'contentType',
|
||||||
'category',
|
'category',
|
||||||
|
|||||||
45
app/Http/Controllers/ContentRouterController.php
Normal file
45
app/Http/Controllers/ContentRouterController.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\ArtworkController;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ContentRouterController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Universal router for content-type roots, nested category paths, and artwork slugs.
|
||||||
|
* Delegates to existing controllers to keep business logic centralized.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, string $contentTypeSlug, ?string $categoryPath = null, $artwork = null)
|
||||||
|
{
|
||||||
|
if (! empty($artwork)) {
|
||||||
|
$normalizedCategoryPath = trim((string) $categoryPath, '/');
|
||||||
|
|
||||||
|
return app(ArtworkController::class)->show($request, $contentTypeSlug, $normalizedCategoryPath, $artwork);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $categoryPath;
|
||||||
|
|
||||||
|
// If no path provided, render the content-type landing (root) page
|
||||||
|
if (empty($path)) {
|
||||||
|
// Special-case photography root to use legacy controller
|
||||||
|
if (strtolower($contentTypeSlug) === 'photography') {
|
||||||
|
return app(\App\Http\Controllers\Legacy\PhotographyController::class)->index($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(\App\Http\Controllers\CategoryPageController::class)->show($request, $contentTypeSlug, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
$segments = array_values(array_filter(explode('/', $path)));
|
||||||
|
if (empty($segments)) {
|
||||||
|
return app(\App\Http\Controllers\CategoryPageController::class)->show($request, $contentTypeSlug, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat the last segment as an artwork slug candidate and delegate to ArtworkController::show
|
||||||
|
$artworkSlug = array_pop($segments);
|
||||||
|
$categoryPath = implode('/', $segments);
|
||||||
|
|
||||||
|
return app(ArtworkController::class)->show($request, $contentTypeSlug, $categoryPath, $artworkSlug);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
namespace App\Http\Controllers\Dashboard;
|
namespace App\Http\Controllers\Dashboard;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Dashboard\ArtworkEditRequest;
|
||||||
|
use App\Http\Requests\Dashboard\ArtworkDestroyRequest;
|
||||||
use App\Http\Requests\Dashboard\UpdateArtworkRequest;
|
use App\Http\Requests\Dashboard\UpdateArtworkRequest;
|
||||||
use App\Models\Artwork;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
@@ -25,10 +26,9 @@ class ArtworkController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(Request $request, int $id): View
|
public function edit(ArtworkEditRequest $request, int $id): View
|
||||||
{
|
{
|
||||||
$artwork = $request->user()->artworks()->whereKey($id)->firstOrFail();
|
$artwork = $request->artwork();
|
||||||
$this->authorize('update', $artwork);
|
|
||||||
|
|
||||||
return view('artworks.edit', [
|
return view('artworks.edit', [
|
||||||
'artwork' => $artwork,
|
'artwork' => $artwork,
|
||||||
@@ -38,8 +38,7 @@ class ArtworkController extends Controller
|
|||||||
|
|
||||||
public function update(UpdateArtworkRequest $request, int $id): RedirectResponse
|
public function update(UpdateArtworkRequest $request, int $id): RedirectResponse
|
||||||
{
|
{
|
||||||
$artwork = $request->user()->artworks()->whereKey($id)->firstOrFail();
|
$artwork = $request->artwork();
|
||||||
$this->authorize('update', $artwork);
|
|
||||||
|
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
|
|
||||||
@@ -83,10 +82,9 @@ class ArtworkController extends Controller
|
|||||||
->with('status', 'Artwork updated.');
|
->with('status', 'Artwork updated.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Request $request, int $id): RedirectResponse
|
public function destroy(ArtworkDestroyRequest $request, int $id): RedirectResponse
|
||||||
{
|
{
|
||||||
$artwork = $request->user()->artworks()->whereKey($id)->firstOrFail();
|
$artwork = $request->artwork();
|
||||||
$this->authorize('delete', $artwork);
|
|
||||||
|
|
||||||
// Best-effort remove stored file.
|
// Best-effort remove stored file.
|
||||||
if (! empty($artwork->file_path) && Storage::disk('public')->exists($artwork->file_path)) {
|
if (! empty($artwork->file_path) && Storage::disk('public')->exists($artwork->file_path)) {
|
||||||
|
|||||||
@@ -21,13 +21,23 @@ class PhotographyController extends Controller
|
|||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
// Legacy group mapping: Photography => id 3
|
// Legacy group mapping: Photography => id 3
|
||||||
$group = 'Photography';
|
// Determine the requested content type from the first URL segment (photography|wallpapers|skins)
|
||||||
$id = 3;
|
$segment = strtolower($request->segment(1) ?? 'photography');
|
||||||
|
$contentSlug = in_array($segment, ['photography','wallpapers','skins','other']) ? $segment : 'photography';
|
||||||
|
|
||||||
// Fetch legacy category info if available
|
// Human-friendly group name (used by legacy templates)
|
||||||
|
$group = ucfirst($contentSlug);
|
||||||
|
|
||||||
|
// Try to load legacy category id only for photography (legacy mapping); otherwise prefer authoritative ContentType
|
||||||
|
$id = null;
|
||||||
|
if ($contentSlug === 'photography') {
|
||||||
|
$id = 3; // legacy root id for photography in oldSite (kept for backward compatibility)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch legacy category info if available (only when we have an id)
|
||||||
$category = null;
|
$category = null;
|
||||||
try {
|
try {
|
||||||
if (Schema::hasTable('artworks_categories')) {
|
if ($id !== null && Schema::hasTable('artworks_categories')) {
|
||||||
$category = DB::table('artworks_categories')
|
$category = DB::table('artworks_categories')
|
||||||
->select('category_name', 'rootid', 'section_id', 'description', 'category_id')
|
->select('category_name', 'rootid', 'section_id', 'description', 'category_id')
|
||||||
->where('category_id', $id)
|
->where('category_id', $id)
|
||||||
@@ -37,22 +47,41 @@ class PhotographyController extends Controller
|
|||||||
$category = null;
|
$category = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$page_title = $category->category_name ?? 'Photography';
|
// Page title and description: prefer legacy category when present, otherwise use ContentType data
|
||||||
$tidy = $category->description ?? null;
|
$ct = ContentType::where('slug', $contentSlug)->first();
|
||||||
|
$page_title = $category->category_name ?? ($ct->name ?? ucfirst($contentSlug));
|
||||||
|
$tidy = $category->description ?? ($ct->description ?? null);
|
||||||
|
|
||||||
$perPage = 40;
|
$perPage = 40;
|
||||||
|
|
||||||
// Use ArtworkService to get artworks for the content type 'photography'
|
// Load artworks for the requested content type using standard pagination
|
||||||
try {
|
try {
|
||||||
$artworks = $this->artworks->getArtworksByContentType('photography', $perPage);
|
$artQuery = \App\Models\Artwork::public()
|
||||||
|
->published()
|
||||||
|
->whereHas('categories', function ($q) use ($ct) {
|
||||||
|
$q->where('categories.content_type_id', $ct->id);
|
||||||
|
})
|
||||||
|
->with([
|
||||||
|
'user:id,name',
|
||||||
|
'categories' => function ($q) {
|
||||||
|
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||||
|
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
|
||||||
|
},
|
||||||
|
])
|
||||||
|
->orderByDesc('published_at');
|
||||||
|
|
||||||
|
$artworks = $artQuery->paginate($perPage)->withQueryString();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$artworks = collect();
|
// Return an empty paginator so views using ->links() / ->firstItem() work
|
||||||
|
$artworks = new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage, 1, [
|
||||||
|
'path' => url()->current(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load subcategories (legacy) if available
|
// Load subcategories: prefer legacy table when id present and data exists, otherwise use ContentType root categories
|
||||||
$subcategories = collect();
|
$subcategories = collect();
|
||||||
try {
|
try {
|
||||||
if (Schema::hasTable('artworks_categories')) {
|
if ($id !== null && Schema::hasTable('artworks_categories')) {
|
||||||
$subcategories = DB::table('artworks_categories')->select('category_id','category_name')->where('rootid', $id)->orderBy('category_name')->get();
|
$subcategories = DB::table('artworks_categories')->select('category_id','category_name')->where('rootid', $id)->orderBy('category_name')->get();
|
||||||
if ($subcategories->count() == 0 && !empty($category->rootid)) {
|
if ($subcategories->count() == 0 && !empty($category->rootid)) {
|
||||||
$subcategories = DB::table('artworks_categories')->select('category_id','category_name')->where('rootid', $category->rootid)->orderBy('category_name')->get();
|
$subcategories = DB::table('artworks_categories')->select('category_id','category_name')->where('rootid', $category->rootid)->orderBy('category_name')->get();
|
||||||
@@ -62,20 +91,35 @@ class PhotographyController extends Controller
|
|||||||
$subcategories = collect();
|
$subcategories = collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to authoritative categories table when legacy table is missing/empty
|
|
||||||
if (! $subcategories || $subcategories->count() === 0) {
|
if (! $subcategories || $subcategories->count() === 0) {
|
||||||
$ct = ContentType::where('slug', 'photography')->first();
|
|
||||||
if ($ct) {
|
if ($ct) {
|
||||||
$subcategories = $ct->rootCategories()
|
$subcategories = $ct->rootCategories()
|
||||||
->orderBy('sort_order')
|
->orderBy('sort_order')
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
->get()
|
->get()
|
||||||
->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name]);
|
->map(fn ($c) => (object) ['category_id' => $c->id, 'category_name' => $c->name, 'slug' => $c->slug]);
|
||||||
} else {
|
} else {
|
||||||
$subcategories = collect();
|
$subcategories = collect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('legacy.photography', compact('page_title','tidy','group','artworks','subcategories','id'));
|
// Coerce collections to a paginator so the view's pagination helpers work
|
||||||
|
if ($artworks instanceof \Illuminate\Database\Eloquent\Collection || $artworks instanceof \Illuminate\Support\Collection) {
|
||||||
|
$page = (int) ($request->query('page', 1));
|
||||||
|
$artworks = new \Illuminate\Pagination\LengthAwarePaginator($artworks->values()->all(), $artworks->count(), $perPage, $page, [
|
||||||
|
'path' => url()->current(),
|
||||||
|
'query' => request()->query(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare variables for the modern content-type view
|
||||||
|
$contentType = ContentType::where('slug', $contentSlug)->first();
|
||||||
|
$rootCategories = $contentType
|
||||||
|
? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get()
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
$page_meta_description = $tidy;
|
||||||
|
|
||||||
|
return view('legacy.content-type', compact('contentType','rootCategories','artworks','page_title','page_meta_description','subcategories','id'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,29 +37,46 @@ class UserController extends Controller
|
|||||||
$request->session()->flash('error', 'Password not changed.');
|
$request->session()->flash('error', 'Password not changed.');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$data = $request->only(['real_name','web','country_code','signature','description','about_me']);
|
// Map legacy form fields into the modern schema.
|
||||||
$user->real_name = $data['real_name'] ?? $user->real_name;
|
$data = $request->only(['name','web','country_code','signature','description','about_me']);
|
||||||
$user->web = $data['web'] ?? $user->web;
|
|
||||||
$user->country_code = $data['country_code'] ?? $user->country_code;
|
// Core user column: `name`
|
||||||
$user->signature = $data['signature'] ?? $user->signature;
|
if (isset($data['name'])) {
|
||||||
$user->description = $data['description'] ?? $user->description;
|
$user->name = $data['name'] ?? $user->name;
|
||||||
$user->about_me = $data['about_me'] ?? $user->about_me;
|
}
|
||||||
|
|
||||||
|
// Collect other profile updates to persist into `user_profiles` when available
|
||||||
|
$profileUpdates = [];
|
||||||
|
if (!empty($data['web'])) $profileUpdates['website'] = $data['web'];
|
||||||
|
if (!empty($data['signature'])) $profileUpdates['signature'] = $data['signature'];
|
||||||
|
if (!empty($data['description'])) $profileUpdates['description'] = $data['description'];
|
||||||
|
if (!empty($data['about_me'])) $profileUpdates['about'] = $data['about_me'];
|
||||||
|
if (!empty($data['country_code'])) $profileUpdates['country_code'] = $data['country_code'];
|
||||||
|
|
||||||
$d1 = $request->input('date1');
|
$d1 = $request->input('date1');
|
||||||
$d2 = $request->input('date2');
|
$d2 = $request->input('date2');
|
||||||
$d3 = $request->input('date3');
|
$d3 = $request->input('date3');
|
||||||
if ($d1 && $d2 && $d3) {
|
if ($d1 && $d2 && $d3) {
|
||||||
$user->birth = sprintf('%04d-%02d-%02d', (int)$d3, (int)$d2, (int)$d1);
|
$profileUpdates['birthdate'] = sprintf('%04d-%02d-%02d', (int)$d3, (int)$d2, (int)$d1);
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->gender = $request->input('gender', $user->gender);
|
$userGender = $request->input('gender', $user->gender);
|
||||||
$user->mlist = $request->has('newsletter') ? 1 : 0;
|
if (!empty($userGender)) {
|
||||||
$user->friend_upload_notice = $request->has('friend_upload_notice') ? 1 : 0;
|
$g = strtolower($userGender);
|
||||||
|
$map = ['m' => 'M', 'f' => 'F', 'n' => 'X', 'x' => 'X'];
|
||||||
|
$profileUpdates['gender'] = $map[$g] ?? strtoupper($userGender);
|
||||||
|
}
|
||||||
|
|
||||||
|
$profileUpdates['mlist'] = $request->has('newsletter') ? 1 : 0;
|
||||||
|
$profileUpdates['friend_upload_notice'] = $request->has('friend_upload_notice') ? 1 : 0;
|
||||||
|
|
||||||
|
// Files: avatar/photo/emoticon
|
||||||
if ($request->hasFile('avatar')) {
|
if ($request->hasFile('avatar')) {
|
||||||
$f = $request->file('avatar');
|
$f = $request->file('avatar');
|
||||||
$name = $user->id . '.' . $f->getClientOriginalExtension();
|
$name = $user->id . '.' . $f->getClientOriginalExtension();
|
||||||
$f->move(public_path('avatar'), $name);
|
$f->move(public_path('avatar'), $name);
|
||||||
|
// store filename in profile avatar (legacy field) — modern avatar pipeline will later migrate
|
||||||
|
$profileUpdates['avatar'] = $name;
|
||||||
$user->icon = $name;
|
$user->icon = $name;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +84,7 @@ class UserController extends Controller
|
|||||||
$f = $request->file('personal_picture');
|
$f = $request->file('personal_picture');
|
||||||
$name = $user->id . '.' . $f->getClientOriginalExtension();
|
$name = $user->id . '.' . $f->getClientOriginalExtension();
|
||||||
$f->move(public_path('user-picture'), $name);
|
$f->move(public_path('user-picture'), $name);
|
||||||
|
$profileUpdates['cover_image'] = $name;
|
||||||
$user->picture = $name;
|
$user->picture = $name;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,25 +95,28 @@ class UserController extends Controller
|
|||||||
$user->eicon = $name;
|
$user->eicon = $name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save core user fields
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
|
// Persist profile updates into `user_profiles` when available, otherwise fallback to `users` table
|
||||||
|
try {
|
||||||
|
if (!empty($profileUpdates) && Schema::hasTable('user_profiles')) {
|
||||||
|
DB::table('user_profiles')->updateOrInsert(['user_id' => $user->id], $profileUpdates + ['updated_at' => now(), 'created_at' => now()]);
|
||||||
|
} elseif (!empty($profileUpdates)) {
|
||||||
|
DB::table('users')->where('id', $user->id)->update($profileUpdates);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore persistence errors for legacy path
|
||||||
|
}
|
||||||
|
|
||||||
$request->session()->flash('status', 'Profile updated.');
|
$request->session()->flash('status', 'Profile updated.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare birth date parts for the legacy form
|
// Prepare birth date parts for the legacy form (initialized — parsed after merging profiles)
|
||||||
$birthDay = null;
|
$birthDay = null;
|
||||||
$birthMonth = null;
|
$birthMonth = null;
|
||||||
$birthYear = null;
|
$birthYear = null;
|
||||||
if (! empty($user->birth)) {
|
|
||||||
try {
|
|
||||||
$dt = Carbon::parse($user->birth);
|
|
||||||
$birthDay = $dt->format('d');
|
|
||||||
$birthMonth = $dt->format('m');
|
|
||||||
$birthYear = $dt->format('Y');
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
// ignore parse errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load country list if available (legacy table names)
|
// Load country list if available (legacy table names)
|
||||||
$countries = collect();
|
$countries = collect();
|
||||||
@@ -109,6 +130,50 @@ class UserController extends Controller
|
|||||||
$countries = collect();
|
$countries = collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge modern `user_profiles` and `user_social_links` into the user object for the view
|
||||||
|
try {
|
||||||
|
if (Schema::hasTable('user_profiles')) {
|
||||||
|
$profile = DB::table('user_profiles')->where('user_id', $user->id)->first();
|
||||||
|
if ($profile) {
|
||||||
|
// map modern profile fields onto the legacy user properties/helpers used by the view
|
||||||
|
if (isset($profile->website)) $user->homepage = $profile->website;
|
||||||
|
if (isset($profile->about)) $user->about_me = $profile->about;
|
||||||
|
if (isset($profile->birthdate)) $user->birth = $profile->birthdate;
|
||||||
|
if (isset($profile->gender)) $user->gender = $profile->gender;
|
||||||
|
if (isset($profile->country_code)) $user->country_code = $profile->country_code;
|
||||||
|
if (isset($profile->avatar)) $user->icon = $profile->avatar;
|
||||||
|
if (isset($profile->cover_image)) $user->picture = $profile->cover_image;
|
||||||
|
if (isset($profile->signature)) $user->signature = $profile->signature;
|
||||||
|
if (isset($profile->description)) $user->description = $profile->description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore profile merge errors
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Schema::hasTable('user_social_links')) {
|
||||||
|
$social = DB::table('user_social_links')->where('user_id', $user->id)->first();
|
||||||
|
if ($social) {
|
||||||
|
$user->social = $social;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore social links errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse birth date parts after merging `user_profiles` so profile birthdate is used
|
||||||
|
if (! empty($user->birth)) {
|
||||||
|
try {
|
||||||
|
$dt = Carbon::parse($user->birth);
|
||||||
|
$birthDay = $dt->format('d');
|
||||||
|
$birthMonth = $dt->format('m');
|
||||||
|
$birthYear = $dt->format('Y');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return view('legacy.user', [
|
return view('legacy.user', [
|
||||||
'user' => $user,
|
'user' => $user,
|
||||||
'birthDay' => $birthDay,
|
'birthDay' => $birthDay,
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Artwork;
|
use App\Http\Requests\Manage\ManageArtworkEditRequest;
|
||||||
use App\Models\ArtworkCategory;
|
use App\Http\Requests\Manage\ManageArtworkUpdateRequest;
|
||||||
|
use App\Http\Requests\Manage\ManageArtworkDestroyRequest;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -38,13 +39,9 @@ class ManageController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(Request $request, $id)
|
public function edit(ManageArtworkEditRequest $request, $id)
|
||||||
{
|
{
|
||||||
$userId = $request->user()->id;
|
$artwork = $request->artwork();
|
||||||
$artwork = DB::table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first();
|
|
||||||
if (! $artwork) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If artworks no longer have a single `category` column, fetch pivot selection
|
// If artworks no longer have a single `category` column, fetch pivot selection
|
||||||
$selectedCategory = DB::table('artwork_category')->where('artwork_id', (int)$id)->value('category_id');
|
$selectedCategory = DB::table('artwork_category')->where('artwork_id', (int)$id)->value('category_id');
|
||||||
@@ -63,22 +60,10 @@ class ManageController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, $id)
|
public function update(ManageArtworkUpdateRequest $request, $id)
|
||||||
{
|
{
|
||||||
$userId = $request->user()->id;
|
$existing = $request->artwork();
|
||||||
$existing = DB::table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first();
|
$data = $request->validated();
|
||||||
|
|
||||||
if (! $existing) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = $request->validate([
|
|
||||||
'name' => 'required|string|max:255',
|
|
||||||
'section' => 'nullable|integer',
|
|
||||||
'description' => 'nullable|string',
|
|
||||||
'artwork' => 'nullable|file|image',
|
|
||||||
'attachment' => 'nullable|file',
|
|
||||||
]);
|
|
||||||
$update = [
|
$update = [
|
||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
'description' => $data['description'] ?? $existing->description,
|
'description' => $data['description'] ?? $existing->description,
|
||||||
@@ -100,7 +85,7 @@ class ManageController extends Controller
|
|||||||
$update['fname'] = basename($attPath);
|
$update['fname'] = basename($attPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::table('artworks')->where('id', (int)$id)->where('user_id', $userId)->update($update);
|
DB::table('artworks')->where('id', (int)$id)->update($update);
|
||||||
|
|
||||||
// Update pivot: set single category selection for this artwork
|
// Update pivot: set single category selection for this artwork
|
||||||
if (isset($data['section'])) {
|
if (isset($data['section'])) {
|
||||||
@@ -114,13 +99,9 @@ class ManageController extends Controller
|
|||||||
return redirect()->route('manage')->with('status', 'Artwork was successfully updated.');
|
return redirect()->route('manage')->with('status', 'Artwork was successfully updated.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Request $request, $id)
|
public function destroy(ManageArtworkDestroyRequest $request, $id)
|
||||||
{
|
{
|
||||||
$userId = $request->user()->id;
|
$artwork = $request->artwork();
|
||||||
$artwork = DB::table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first();
|
|
||||||
if (! $artwork) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// delete files if present (stored in new storage location)
|
// delete files if present (stored in new storage location)
|
||||||
if (!empty($artwork->fname)) {
|
if (!empty($artwork->fname)) {
|
||||||
@@ -130,7 +111,7 @@ class ManageController extends Controller
|
|||||||
Storage::delete('public/uploads/artworks/' . $artwork->picture);
|
Storage::delete('public/uploads/artworks/' . $artwork->picture);
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::table('artworks')->where('id', (int)$id)->where('user_id', $userId)->delete();
|
DB::table('artworks')->where('id', (int)$id)->delete();
|
||||||
|
|
||||||
return redirect()->route('manage')->with('status', 'Artwork deleted.');
|
return redirect()->route('manage')->with('status', 'Artwork deleted.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use Illuminate\Http\Request;
|
|||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Redirect;
|
use Illuminate\Support\Facades\Redirect;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||||
|
|
||||||
class ProfileController extends Controller
|
class ProfileController extends Controller
|
||||||
{
|
{
|
||||||
@@ -24,17 +26,124 @@ class ProfileController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Update the user's profile information.
|
* Update the user's profile information.
|
||||||
*/
|
*/
|
||||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
public function update(ProfileUpdateRequest $request, \App\Services\AvatarService $avatarService): RedirectResponse
|
||||||
{
|
{
|
||||||
$request->user()->fill($request->validated());
|
$user = $request->user();
|
||||||
|
|
||||||
if ($request->user()->isDirty('email')) {
|
// Core fields
|
||||||
$request->user()->email_verified_at = null;
|
$validated = $request->validated();
|
||||||
|
|
||||||
|
logger()->debug('Profile update validated data', $validated);
|
||||||
|
|
||||||
|
// Username is read-only and must not be changed here.
|
||||||
|
// Use `name` for the real/display name field.
|
||||||
|
if (isset($validated['name'])) {
|
||||||
|
$user->name = $validated['name'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$request->user()->save();
|
// Only allow setting email when we don't have one yet (legacy users)
|
||||||
|
if (!empty($validated['email']) && empty($user->email)) {
|
||||||
|
$user->email = $validated['email'];
|
||||||
|
$user->email_verified_at = null;
|
||||||
|
}
|
||||||
|
|
||||||
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
$user->save();
|
||||||
|
|
||||||
|
// Profile fields - target columns in `user_profiles` per spec
|
||||||
|
$profileUpdates = [];
|
||||||
|
if (!empty($validated['about'])) $profileUpdates['about'] = $validated['about'];
|
||||||
|
|
||||||
|
// website / legacy homepage
|
||||||
|
if (!empty($validated['web'])) {
|
||||||
|
$profileUpdates['website'] = $validated['web'];
|
||||||
|
} elseif (!empty($validated['homepage'])) {
|
||||||
|
$profileUpdates['website'] = $validated['homepage'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Birthday -> store as birthdate
|
||||||
|
$day = $validated['day'] ?? null;
|
||||||
|
$month = $validated['month'] ?? null;
|
||||||
|
$year = $validated['year'] ?? null;
|
||||||
|
if ($year && $month && $day) {
|
||||||
|
$profileUpdates['birthdate'] = sprintf('%04d-%02d-%02d', (int)$year, (int)$month, (int)$day);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gender normalization -> store as provided normalized value
|
||||||
|
if (!empty($validated['gender'])) {
|
||||||
|
$g = strtolower($validated['gender']);
|
||||||
|
$map = ['m' => 'M', 'f' => 'F', 'n' => 'X', 'x' => 'X'];
|
||||||
|
$profileUpdates['gender'] = $map[$g] ?? strtoupper($validated['gender']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($validated['country'])) $profileUpdates['country_code'] = $validated['country'];
|
||||||
|
|
||||||
|
// Mailing and notify flags: normalize true/false when saving
|
||||||
|
if (array_key_exists('mailing', $validated)) {
|
||||||
|
$profileUpdates['mlist'] = filter_var($validated['mailing'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
|
||||||
|
}
|
||||||
|
if (array_key_exists('notify', $validated)) {
|
||||||
|
$profileUpdates['friend_upload_notice'] = filter_var($validated['notify'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// signature/description should be stored in their own columns
|
||||||
|
if (isset($validated['signature'])) $profileUpdates['signature'] = $validated['signature'];
|
||||||
|
if (isset($validated['description'])) $profileUpdates['description'] = $validated['description'];
|
||||||
|
|
||||||
|
// 'about' direct field (ensure explicit about wins when provided)
|
||||||
|
if (isset($validated['about'])) $profileUpdates['about'] = $validated['about'];
|
||||||
|
|
||||||
|
// Files: avatar -> use AvatarService, emoticon and photo -> store to public disk
|
||||||
|
if ($request->hasFile('avatar')) {
|
||||||
|
try {
|
||||||
|
$hash = $avatarService->storeFromUploadedFile($user->id, $request->file('avatar'));
|
||||||
|
// store returned hash into profile avatar column
|
||||||
|
if (!empty($hash)) {
|
||||||
|
$profileUpdates['avatar'] = $hash;
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return Redirect::back()->with('error', 'Avatar processing failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->hasFile('emoticon')) {
|
||||||
|
$file = $request->file('emoticon');
|
||||||
|
$fname = $file->getClientOriginalName();
|
||||||
|
$path = \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-emoticons/'.$user->id, $file, $fname);
|
||||||
|
try {
|
||||||
|
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['eicon' => $fname]);
|
||||||
|
} catch (\Exception $e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->hasFile('photo')) {
|
||||||
|
$file = $request->file('photo');
|
||||||
|
$fname = $file->getClientOriginalName();
|
||||||
|
$path = \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname);
|
||||||
|
// store cover image filename in user_profiles.cover_image (fallback to users.picture)
|
||||||
|
if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) {
|
||||||
|
$profileUpdates['cover_image'] = $fname;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['picture' => $fname]);
|
||||||
|
} catch (\Exception $e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist profile updates now that files (avatar/cover) have been handled
|
||||||
|
try {
|
||||||
|
if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) {
|
||||||
|
if (!empty($profileUpdates)) {
|
||||||
|
\Illuminate\Support\Facades\DB::table('user_profiles')->updateOrInsert(['user_id' => $user->id], $profileUpdates);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!empty($profileUpdates)) {
|
||||||
|
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update($profileUpdates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
logger()->error('Profile update error: '.$e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Redirect::to('/user')->with('status', 'profile-updated');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,4 +167,21 @@ class ProfileController extends Controller
|
|||||||
|
|
||||||
return Redirect::to('/');
|
return Redirect::to('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the user's password.
|
||||||
|
*/
|
||||||
|
public function password(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'current_password' => ['required', 'current_password'],
|
||||||
|
'password' => ['required', 'confirmed', PasswordRule::min(8)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
$user->password = Hash::make($request->input('password'));
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
return Redirect::to('/user')->with('status', 'password-updated');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
app/Http/Controllers/Web/TagController.php
Normal file
29
app/Http/Controllers/Web/TagController.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Tag;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
final class TagController extends Controller
|
||||||
|
{
|
||||||
|
public function show(Tag $tag): View
|
||||||
|
{
|
||||||
|
$artworks = $tag->artworks()
|
||||||
|
->public()
|
||||||
|
->published()
|
||||||
|
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||||
|
->orderByDesc('artwork_stats.views')
|
||||||
|
->orderByDesc('artworks.published_at')
|
||||||
|
->select('artworks.*')
|
||||||
|
->paginate(24);
|
||||||
|
|
||||||
|
return view('tags.show', [
|
||||||
|
'tag' => $tag,
|
||||||
|
'artworks' => $artworks,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Http/Middleware/EnsureAdminOrModerator.php
Normal file
24
app/Http/Middleware/EnsureAdminOrModerator.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class EnsureAdminOrModerator
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$role = strtolower((string) ($user->role ?? ''));
|
||||||
|
|
||||||
|
if (! in_array($role, ['admin', 'moderator'], true)) {
|
||||||
|
abort(Response::HTTP_FORBIDDEN, 'Forbidden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Http/Middleware/HandleInertiaRequests.php
Normal file
33
app/Http/Middleware/HandleInertiaRequests.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Middleware;
|
||||||
|
|
||||||
|
final class HandleInertiaRequests extends Middleware
|
||||||
|
{
|
||||||
|
protected $rootView = 'upload';
|
||||||
|
|
||||||
|
public function version(Request $request): ?string
|
||||||
|
{
|
||||||
|
return parent::version($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function share(Request $request): array
|
||||||
|
{
|
||||||
|
return array_merge(parent::share($request), [
|
||||||
|
'auth' => [
|
||||||
|
'user' => $request->user() ? [
|
||||||
|
'id' => $request->user()->id,
|
||||||
|
'name' => $request->user()->name,
|
||||||
|
] : null,
|
||||||
|
],
|
||||||
|
'cdn' => [
|
||||||
|
'files_url' => config('cdn.files_url'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Http/Requests/Artworks/ArtworkCreateRequest.php
Normal file
46
app/Http/Requests/Artworks/ArtworkCreateRequest.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Artworks;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
final class ArtworkCreateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
if (! $this->user()) {
|
||||||
|
$this->logUnauthorized('missing_user');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => 'required|string|max:150',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'category' => 'nullable|string|max:120',
|
||||||
|
'tags' => 'nullable|string|max:200',
|
||||||
|
'license' => 'nullable|boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function denyAsNotFound(): void
|
||||||
|
{
|
||||||
|
throw new NotFoundHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logUnauthorized(string $reason): void
|
||||||
|
{
|
||||||
|
logger()->warning('Artwork create unauthorized access', [
|
||||||
|
'reason' => $reason,
|
||||||
|
'user_id' => $this->user()?->id,
|
||||||
|
'ip' => $this->ip(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Http/Requests/Artworks/ArtworkTagsStoreRequest.php
Normal file
28
app/Http/Requests/Artworks/ArtworkTagsStoreRequest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Artworks;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
final class ArtworkTagsStoreRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
if (! $this->user()) {
|
||||||
|
throw new NotFoundHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tags' => 'required|array|max:15',
|
||||||
|
'tags.*' => 'required|string|max:64',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Http/Requests/Artworks/ArtworkTagsUpdateRequest.php
Normal file
28
app/Http/Requests/Artworks/ArtworkTagsUpdateRequest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Artworks;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
final class ArtworkTagsUpdateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
if (! $this->user()) {
|
||||||
|
throw new NotFoundHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tags' => 'required|array|max:15',
|
||||||
|
'tags.*' => 'required|string|max:64',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Http/Requests/Dashboard/ArtworkDestroyRequest.php
Normal file
68
app/Http/Requests/Dashboard/ArtworkDestroyRequest.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Dashboard;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
final class ArtworkDestroyRequest extends FormRequest
|
||||||
|
{
|
||||||
|
private ?Artwork $artwork = null;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
if (! $user) {
|
||||||
|
$this->logUnauthorized('missing_user');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) $this->route('id');
|
||||||
|
if ($id <= 0) {
|
||||||
|
$this->logUnauthorized('missing_artwork_id');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$artwork = Artwork::query()->whereKey($id)->first();
|
||||||
|
if (! $artwork || (int) $artwork->user_id !== (int) $user->id) {
|
||||||
|
$this->logUnauthorized('artwork_not_owned_or_missing');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->artwork = $artwork;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function artwork(): Artwork
|
||||||
|
{
|
||||||
|
if (! $this->artwork) {
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->artwork;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function denyAsNotFound(): void
|
||||||
|
{
|
||||||
|
throw new NotFoundHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logUnauthorized(string $reason): void
|
||||||
|
{
|
||||||
|
logger()->warning('Dashboard artwork delete unauthorized access', [
|
||||||
|
'reason' => $reason,
|
||||||
|
'artwork_id' => $this->route('id'),
|
||||||
|
'user_id' => $this->user()?->id,
|
||||||
|
'ip' => $this->ip(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Http/Requests/Dashboard/ArtworkEditRequest.php
Normal file
68
app/Http/Requests/Dashboard/ArtworkEditRequest.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Dashboard;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
final class ArtworkEditRequest extends FormRequest
|
||||||
|
{
|
||||||
|
private ?Artwork $artwork = null;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
if (! $user) {
|
||||||
|
$this->logUnauthorized('missing_user');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) $this->route('id');
|
||||||
|
if ($id <= 0) {
|
||||||
|
$this->logUnauthorized('missing_artwork_id');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$artwork = Artwork::query()->whereKey($id)->first();
|
||||||
|
if (! $artwork || (int) $artwork->user_id !== (int) $user->id) {
|
||||||
|
$this->logUnauthorized('artwork_not_owned_or_missing');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->artwork = $artwork;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function artwork(): Artwork
|
||||||
|
{
|
||||||
|
if (! $this->artwork) {
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->artwork;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function denyAsNotFound(): void
|
||||||
|
{
|
||||||
|
throw new NotFoundHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logUnauthorized(string $reason): void
|
||||||
|
{
|
||||||
|
logger()->warning('Dashboard artwork edit unauthorized access', [
|
||||||
|
'reason' => $reason,
|
||||||
|
'artwork_id' => $this->route('id'),
|
||||||
|
'user_id' => $this->user()?->id,
|
||||||
|
'ip' => $this->ip(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,36 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests\Dashboard;
|
namespace App\Http\Requests\Dashboard;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
class UpdateArtworkRequest extends FormRequest
|
class UpdateArtworkRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
private ?Artwork $artwork = null;
|
||||||
|
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
// Authorization is enforced in the controller via ArtworkPolicy.
|
$user = $this->user();
|
||||||
|
if (! $user) {
|
||||||
|
$this->logUnauthorized('missing_user');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) $this->route('id');
|
||||||
|
if ($id <= 0) {
|
||||||
|
$this->logUnauthorized('missing_artwork_id');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$artwork = Artwork::query()->whereKey($id)->first();
|
||||||
|
if (! $artwork || (int) $artwork->user_id !== (int) $user->id) {
|
||||||
|
$this->logUnauthorized('artwork_not_owned_or_missing');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->artwork = $artwork;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,4 +44,28 @@ class UpdateArtworkRequest extends FormRequest
|
|||||||
'file' => 'nullable|image|max:102400',
|
'file' => 'nullable|image|max:102400',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function artwork(): Artwork
|
||||||
|
{
|
||||||
|
if (! $this->artwork) {
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->artwork;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function denyAsNotFound(): void
|
||||||
|
{
|
||||||
|
throw new NotFoundHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logUnauthorized(string $reason): void
|
||||||
|
{
|
||||||
|
logger()->warning('Dashboard artwork update unauthorized access', [
|
||||||
|
'reason' => $reason,
|
||||||
|
'artwork_id' => $this->route('id'),
|
||||||
|
'user_id' => $this->user()?->id,
|
||||||
|
'ip' => $this->ip(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
68
app/Http/Requests/Manage/ManageArtworkDestroyRequest.php
Normal file
68
app/Http/Requests/Manage/ManageArtworkDestroyRequest.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Manage;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
final class ManageArtworkDestroyRequest extends FormRequest
|
||||||
|
{
|
||||||
|
private ?object $artwork = null;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
if (! $user) {
|
||||||
|
$this->logUnauthorized('missing_user');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) $this->route('id');
|
||||||
|
if ($id <= 0) {
|
||||||
|
$this->logUnauthorized('missing_artwork_id');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$artwork = DB::table('artworks')->where('id', $id)->first();
|
||||||
|
if (! $artwork || (int) $artwork->user_id !== (int) $user->id) {
|
||||||
|
$this->logUnauthorized('artwork_not_owned_or_missing');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->artwork = $artwork;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function artwork(): object
|
||||||
|
{
|
||||||
|
if (! $this->artwork) {
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->artwork;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function denyAsNotFound(): void
|
||||||
|
{
|
||||||
|
throw new NotFoundHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logUnauthorized(string $reason): void
|
||||||
|
{
|
||||||
|
logger()->warning('Manage artwork delete unauthorized access', [
|
||||||
|
'reason' => $reason,
|
||||||
|
'artwork_id' => $this->route('id'),
|
||||||
|
'user_id' => $this->user()?->id,
|
||||||
|
'ip' => $this->ip(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Http/Requests/Manage/ManageArtworkEditRequest.php
Normal file
68
app/Http/Requests/Manage/ManageArtworkEditRequest.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Manage;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
final class ManageArtworkEditRequest extends FormRequest
|
||||||
|
{
|
||||||
|
private ?object $artwork = null;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
if (! $user) {
|
||||||
|
$this->logUnauthorized('missing_user');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) $this->route('id');
|
||||||
|
if ($id <= 0) {
|
||||||
|
$this->logUnauthorized('missing_artwork_id');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$artwork = DB::table('artworks')->where('id', $id)->first();
|
||||||
|
if (! $artwork || (int) $artwork->user_id !== (int) $user->id) {
|
||||||
|
$this->logUnauthorized('artwork_not_owned_or_missing');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->artwork = $artwork;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function artwork(): object
|
||||||
|
{
|
||||||
|
if (! $this->artwork) {
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->artwork;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function denyAsNotFound(): void
|
||||||
|
{
|
||||||
|
throw new NotFoundHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logUnauthorized(string $reason): void
|
||||||
|
{
|
||||||
|
logger()->warning('Manage artwork edit unauthorized access', [
|
||||||
|
'reason' => $reason,
|
||||||
|
'artwork_id' => $this->route('id'),
|
||||||
|
'user_id' => $this->user()?->id,
|
||||||
|
'ip' => $this->ip(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/Http/Requests/Manage/ManageArtworkUpdateRequest.php
Normal file
74
app/Http/Requests/Manage/ManageArtworkUpdateRequest.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Manage;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
final class ManageArtworkUpdateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
private ?object $artwork = null;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
if (! $user) {
|
||||||
|
$this->logUnauthorized('missing_user');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) $this->route('id');
|
||||||
|
if ($id <= 0) {
|
||||||
|
$this->logUnauthorized('missing_artwork_id');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$artwork = DB::table('artworks')->where('id', $id)->first();
|
||||||
|
if (! $artwork || (int) $artwork->user_id !== (int) $user->id) {
|
||||||
|
$this->logUnauthorized('artwork_not_owned_or_missing');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->artwork = $artwork;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'section' => 'nullable|integer',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'artwork' => 'nullable|file|image',
|
||||||
|
'attachment' => 'nullable|file',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function artwork(): object
|
||||||
|
{
|
||||||
|
if (! $this->artwork) {
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->artwork;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function denyAsNotFound(): void
|
||||||
|
{
|
||||||
|
throw new NotFoundHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logUnauthorized(string $reason): void
|
||||||
|
{
|
||||||
|
logger()->warning('Manage artwork update unauthorized access', [
|
||||||
|
'reason' => $reason,
|
||||||
|
'artwork_id' => $this->route('id'),
|
||||||
|
'user_id' => $this->user()?->id,
|
||||||
|
'ip' => $this->ip(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ class ProfileUpdateRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'username' => ['sometimes', 'string', 'max:255'],
|
||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
@@ -25,6 +25,21 @@ class ProfileUpdateRequest extends FormRequest
|
|||||||
'max:255',
|
'max:255',
|
||||||
Rule::unique(User::class)->ignore($this->user()->id),
|
Rule::unique(User::class)->ignore($this->user()->id),
|
||||||
],
|
],
|
||||||
|
'name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'web' => ['nullable', 'url', 'max:255'],
|
||||||
|
'day' => ['nullable', 'numeric', 'between:1,31'],
|
||||||
|
'month' => ['nullable', 'numeric', 'between:1,12'],
|
||||||
|
'year' => ['nullable', 'numeric', 'digits:4'],
|
||||||
|
'gender' => ['nullable', 'in:m,f,n,M,F,N,X,x'],
|
||||||
|
'country' => ['nullable', 'string', 'max:10'],
|
||||||
|
'mailing' => ['nullable', 'boolean'],
|
||||||
|
'notify' => ['nullable', 'boolean'],
|
||||||
|
'about' => ['nullable', 'string'],
|
||||||
|
'signature' => ['nullable', 'string'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'avatar' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'],
|
||||||
|
'emoticon' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'],
|
||||||
|
'photo' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
app/Http/Requests/Tags/PopularTagsRequest.php
Normal file
27
app/Http/Requests/Tags/PopularTagsRequest.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Tags;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
final class PopularTagsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
if (! $this->user()) {
|
||||||
|
throw new NotFoundHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'limit' => 'nullable|integer|min:1|max:50',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Requests/Tags/TagSearchRequest.php
Normal file
27
app/Http/Requests/Tags/TagSearchRequest.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Tags;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
final class TagSearchRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
if (! $this->user()) {
|
||||||
|
throw new NotFoundHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'q' => 'nullable|string|max:64',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/Http/Requests/Uploads/UploadCancelRequest.php
Normal file
83
app/Http/Requests/Uploads/UploadCancelRequest.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Uploads;
|
||||||
|
|
||||||
|
use App\Repositories\Uploads\UploadSessionRepository;
|
||||||
|
use App\Services\Uploads\UploadTokenService;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
final class UploadCancelRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
if (! $user) {
|
||||||
|
$this->logUnauthorized('missing_user');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionId = (string) $this->input('session_id');
|
||||||
|
if ($sessionId === '') {
|
||||||
|
$this->logUnauthorized('missing_session_id');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $this->header('X-Upload-Token') ?: $this->input('upload_token');
|
||||||
|
if (! $token) {
|
||||||
|
$this->logUnauthorized('missing_token');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessions = $this->container->make(UploadSessionRepository::class);
|
||||||
|
$session = $sessions->get($sessionId);
|
||||||
|
if (! $session || $session->userId !== $user->id) {
|
||||||
|
$this->logUnauthorized('not_owned_or_missing');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = $this->container->make(UploadTokenService::class);
|
||||||
|
$payload = $tokens->get((string) $token);
|
||||||
|
if (! $payload) {
|
||||||
|
$this->logUnauthorized('invalid_token');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($payload['session_id'] ?? null) !== $sessionId) {
|
||||||
|
$this->logUnauthorized('token_session_mismatch');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($payload['user_id'] ?? 0) !== (int) $user->id) {
|
||||||
|
$this->logUnauthorized('token_user_mismatch');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'session_id' => 'required|uuid',
|
||||||
|
'upload_token' => 'nullable|string|min:40|max:200',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function denyAsNotFound(): void
|
||||||
|
{
|
||||||
|
throw new NotFoundHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logUnauthorized(string $reason): void
|
||||||
|
{
|
||||||
|
logger()->warning('Upload cancel unauthorized access', [
|
||||||
|
'reason' => $reason,
|
||||||
|
'session_id' => (string) $this->input('session_id'),
|
||||||
|
'user_id' => $this->user()?->id,
|
||||||
|
'ip' => $this->ip(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/Http/Requests/Uploads/UploadChunkRequest.php
Normal file
91
app/Http/Requests/Uploads/UploadChunkRequest.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Uploads;
|
||||||
|
|
||||||
|
use App\Repositories\Uploads\UploadSessionRepository;
|
||||||
|
use App\Services\Uploads\UploadTokenService;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
final class UploadChunkRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
if (! $user) {
|
||||||
|
$this->logUnauthorized('missing_user');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionId = (string) $this->input('session_id');
|
||||||
|
if ($sessionId === '') {
|
||||||
|
$this->logUnauthorized('missing_session_id');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $this->header('X-Upload-Token') ?: $this->input('upload_token');
|
||||||
|
if (! $token) {
|
||||||
|
$this->logUnauthorized('missing_token');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessions = $this->container->make(UploadSessionRepository::class);
|
||||||
|
$session = $sessions->get($sessionId);
|
||||||
|
if (! $session || $session->userId !== $user->id) {
|
||||||
|
$this->logUnauthorized('not_owned_or_missing');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = $this->container->make(UploadTokenService::class);
|
||||||
|
$payload = $tokens->get((string) $token);
|
||||||
|
if (! $payload) {
|
||||||
|
$this->logUnauthorized('invalid_token');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($payload['session_id'] ?? null) !== $sessionId) {
|
||||||
|
$this->logUnauthorized('token_session_mismatch');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($payload['user_id'] ?? 0) !== (int) $user->id) {
|
||||||
|
$this->logUnauthorized('token_user_mismatch');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$maxBytes = (int) config('uploads.chunk.max_bytes', 0);
|
||||||
|
$maxKb = $maxBytes > 0 ? (int) ceil($maxBytes / 1024) : 5120;
|
||||||
|
$chunkSizeRule = $maxBytes > 0 ? 'required|integer|min:1|max:' . $maxBytes : 'required|integer|min:1';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'session_id' => 'required|uuid',
|
||||||
|
'offset' => 'required|integer|min:0',
|
||||||
|
'total_size' => 'required|integer|min:1',
|
||||||
|
'chunk_size' => $chunkSizeRule,
|
||||||
|
'chunk' => 'required|file|max:' . $maxKb,
|
||||||
|
'upload_token' => 'nullable|string|min:40|max:200',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function denyAsNotFound(): void
|
||||||
|
{
|
||||||
|
throw new NotFoundHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logUnauthorized(string $reason): void
|
||||||
|
{
|
||||||
|
logger()->warning('Upload chunk unauthorized access', [
|
||||||
|
'reason' => $reason,
|
||||||
|
'session_id' => (string) $this->input('session_id'),
|
||||||
|
'user_id' => $this->user()?->id,
|
||||||
|
'ip' => $this->ip(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
app/Http/Requests/Uploads/UploadFinishRequest.php
Normal file
108
app/Http/Requests/Uploads/UploadFinishRequest.php
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Uploads;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Repositories\Uploads\UploadSessionRepository;
|
||||||
|
use App\Services\Uploads\UploadTokenService;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
final class UploadFinishRequest extends FormRequest
|
||||||
|
{
|
||||||
|
private ?Artwork $artwork = null;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
if (! $user) {
|
||||||
|
$this->logUnauthorized('missing_user');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionId = (string) $this->input('session_id');
|
||||||
|
if ($sessionId === '') {
|
||||||
|
$this->logUnauthorized('missing_session_id');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessions = $this->container->make(UploadSessionRepository::class);
|
||||||
|
$session = $sessions->get($sessionId);
|
||||||
|
if (! $session || $session->userId !== $user->id) {
|
||||||
|
$this->logUnauthorized('not_owned_or_missing');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $this->header('X-Upload-Token') ?: $this->input('upload_token');
|
||||||
|
if ($token) {
|
||||||
|
$tokens = $this->container->make(UploadTokenService::class);
|
||||||
|
$payload = $tokens->get((string) $token);
|
||||||
|
if (! $payload) {
|
||||||
|
$this->logUnauthorized('invalid_token');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($payload['session_id'] ?? null) !== $sessionId) {
|
||||||
|
$this->logUnauthorized('token_session_mismatch');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($payload['user_id'] ?? 0) !== (int) $user->id) {
|
||||||
|
$this->logUnauthorized('token_user_mismatch');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$artworkId = (int) $this->input('artwork_id');
|
||||||
|
if ($artworkId <= 0) {
|
||||||
|
$this->logUnauthorized('missing_artwork_id');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$artwork = Artwork::query()->find($artworkId);
|
||||||
|
if (! $artwork || (int) $artwork->user_id !== (int) $user->id) {
|
||||||
|
$this->logUnauthorized('artwork_not_owned_or_missing');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->artwork = $artwork;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'session_id' => 'required|uuid',
|
||||||
|
'artwork_id' => 'required|integer',
|
||||||
|
'upload_token' => 'nullable|string|min:40|max:200',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function artwork(): Artwork
|
||||||
|
{
|
||||||
|
if (! $this->artwork) {
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->artwork;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function denyAsNotFound(): void
|
||||||
|
{
|
||||||
|
throw new NotFoundHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logUnauthorized(string $reason): void
|
||||||
|
{
|
||||||
|
logger()->warning('Upload finish unauthorized access', [
|
||||||
|
'reason' => $reason,
|
||||||
|
'session_id' => (string) $this->input('session_id'),
|
||||||
|
'artwork_id' => $this->input('artwork_id'),
|
||||||
|
'user_id' => $this->user()?->id,
|
||||||
|
'ip' => $this->ip(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Http/Requests/Uploads/UploadInitRequest.php
Normal file
22
app/Http/Requests/Uploads/UploadInitRequest.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Uploads;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
final class UploadInitRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return (bool) $this->user();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'client' => 'nullable|string|max:64',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
87
app/Http/Requests/Uploads/UploadStatusRequest.php
Normal file
87
app/Http/Requests/Uploads/UploadStatusRequest.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Uploads;
|
||||||
|
|
||||||
|
use App\Repositories\Uploads\UploadSessionRepository;
|
||||||
|
use App\Services\Uploads\UploadTokenService;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
final class UploadStatusRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
if (! $user) {
|
||||||
|
$this->logUnauthorized('missing_user');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionId = (string) $this->route('id');
|
||||||
|
if ($sessionId === '') {
|
||||||
|
$this->logUnauthorized('missing_session_id');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessions = $this->container->make(UploadSessionRepository::class);
|
||||||
|
$session = $sessions->get($sessionId);
|
||||||
|
if (! $session || $session->userId !== $user->id) {
|
||||||
|
$this->logUnauthorized('not_owned_or_missing');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $this->header('X-Upload-Token') ?: $this->input('upload_token');
|
||||||
|
if ($token) {
|
||||||
|
$tokens = $this->container->make(UploadTokenService::class);
|
||||||
|
$payload = $tokens->get((string) $token);
|
||||||
|
if (! $payload) {
|
||||||
|
$this->logUnauthorized('invalid_token');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($payload['session_id'] ?? null) !== $sessionId) {
|
||||||
|
$this->logUnauthorized('token_session_mismatch');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($payload['user_id'] ?? 0) !== (int) $user->id) {
|
||||||
|
$this->logUnauthorized('token_user_mismatch');
|
||||||
|
$this->denyAsNotFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function denyAsNotFound(): void
|
||||||
|
{
|
||||||
|
throw new NotFoundHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logUnauthorized(string $reason): void
|
||||||
|
{
|
||||||
|
logger()->warning('Upload status unauthorized access', [
|
||||||
|
'reason' => $reason,
|
||||||
|
'session_id' => (string) $this->route('id'),
|
||||||
|
'user_id' => $this->user()?->id,
|
||||||
|
'ip' => $this->ip(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => 'required|uuid',
|
||||||
|
'upload_token' => 'nullable|string|min:40|max:200',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->merge([
|
||||||
|
'id' => $this->route('id'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
401
app/Jobs/AutoTagArtworkJob.php
Normal file
401
app/Jobs/AutoTagArtworkJob.php
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Services\TagNormalizer;
|
||||||
|
use App\Services\TagService;
|
||||||
|
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\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
final class AutoTagArtworkJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep retries low; tagging must never block publish.
|
||||||
|
*/
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard timeout safety for queue workers.
|
||||||
|
*/
|
||||||
|
public int $timeout = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $artworkId
|
||||||
|
* @param string $hash File hash used to build public derivative URLs.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $artworkId,
|
||||||
|
private readonly string $hash,
|
||||||
|
) {
|
||||||
|
$queue = (string) config('vision.queue', 'default');
|
||||||
|
if ($queue !== '') {
|
||||||
|
$this->onQueue($queue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function backoff(): array
|
||||||
|
{
|
||||||
|
return [2, 10, 30];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(TagService $tagService, TagNormalizer $normalizer): void
|
||||||
|
{
|
||||||
|
if (! (bool) config('vision.enabled', true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$artwork = Artwork::query()->with(['categories.contentType'])->find($this->artworkId);
|
||||||
|
if (! $artwork) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$imageUrl = $this->buildImageUrl($this->hash);
|
||||||
|
if ($imageUrl === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$processingKey = $this->processingKey($this->artworkId, $this->hash);
|
||||||
|
if (! $this->acquireProcessingLock($processingKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ref = (string) Str::uuid();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$clipTags = $this->callClip($imageUrl, $ref);
|
||||||
|
|
||||||
|
$yoloTags = [];
|
||||||
|
if ($this->shouldRunYolo($artwork)) {
|
||||||
|
$yoloTags = $this->callYolo($imageUrl, $ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
$merged = $this->mergeTags($clipTags, $yoloTags);
|
||||||
|
if ($merged === []) {
|
||||||
|
$this->markProcessed($this->processedKey($this->artworkId, $this->hash));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize explicitly (requirement), then attach via TagService (source=ai + confidence).
|
||||||
|
$payload = [];
|
||||||
|
foreach ($merged as $row) {
|
||||||
|
$tag = $normalizer->normalize((string) ($row['tag'] ?? ''));
|
||||||
|
if ($tag === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$payload[] = [
|
||||||
|
'tag' => $tag,
|
||||||
|
'confidence' => isset($row['confidence']) && is_numeric($row['confidence']) ? (float) $row['confidence'] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagService->attachAiTags($artwork, $payload);
|
||||||
|
|
||||||
|
$this->markProcessed($this->processedKey($this->artworkId, $this->hash));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('AutoTagArtworkJob failed', [
|
||||||
|
'ref' => $ref,
|
||||||
|
'artwork_id' => $this->artworkId,
|
||||||
|
'hash' => $this->hash,
|
||||||
|
'attempt' => $this->attempts(),
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Retry-safe: allow queue retry on transient failures.
|
||||||
|
throw $e;
|
||||||
|
} finally {
|
||||||
|
$this->releaseProcessingLock($processingKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildImageUrl(string $hash): ?string
|
||||||
|
{
|
||||||
|
$base = (string) config('cdn.files_url');
|
||||||
|
$base = rtrim($base, '/');
|
||||||
|
if ($base === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant = (string) config('vision.image_variant', 'md');
|
||||||
|
$variant = $variant !== '' ? $variant : 'md';
|
||||||
|
|
||||||
|
// Matches the upload public path layout used for derivatives (img/aa/bb/cc/variant.webp).
|
||||||
|
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash));
|
||||||
|
$clean = str_pad($clean, 6, '0');
|
||||||
|
$segments = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00'];
|
||||||
|
$path = 'img/' . implode('/', $segments) . '/' . $variant . '.webp';
|
||||||
|
|
||||||
|
return $base . '/' . $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{tag: string, confidence?: float|int|null}>
|
||||||
|
*/
|
||||||
|
private function callClip(string $imageUrl, string $ref): array
|
||||||
|
{
|
||||||
|
$base = trim((string) config('vision.clip.base_url', ''));
|
||||||
|
if ($base === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$endpoint = (string) config('vision.clip.endpoint', '/analyze');
|
||||||
|
$url = rtrim($base, '/') . '/' . ltrim($endpoint, '/');
|
||||||
|
|
||||||
|
$timeout = (int) config('vision.clip.timeout_seconds', 8);
|
||||||
|
$connectTimeout = (int) config('vision.clip.connect_timeout_seconds', 2);
|
||||||
|
$retries = (int) config('vision.clip.retries', 1);
|
||||||
|
$delay = (int) config('vision.clip.retry_delay_ms', 200);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::acceptJson()
|
||||||
|
->connectTimeout(max(1, $connectTimeout))
|
||||||
|
->timeout(max(1, $timeout))
|
||||||
|
->retry(max(0, $retries), max(0, $delay), throw: false)
|
||||||
|
->post($url, [
|
||||||
|
'image_url' => $imageUrl,
|
||||||
|
'artwork_id' => $this->artworkId,
|
||||||
|
'hash' => $this->hash,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('CLIP analyze request failed', ['ref' => $ref, 'artwork_id' => $this->artworkId, 'error' => $e->getMessage()]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->serverError()) {
|
||||||
|
Log::warning('CLIP analyze server error', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]);
|
||||||
|
throw new \RuntimeException('CLIP server error: ' . $response->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $response->ok()) {
|
||||||
|
Log::warning('CLIP analyze non-ok response', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->extractTagList($response->json());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{tag: string, confidence?: float|int|null}>
|
||||||
|
*/
|
||||||
|
private function callYolo(string $imageUrl, string $ref): array
|
||||||
|
{
|
||||||
|
if (! (bool) config('vision.yolo.enabled', true)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = trim((string) config('vision.yolo.base_url', ''));
|
||||||
|
if ($base === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$endpoint = (string) config('vision.yolo.endpoint', '/analyze');
|
||||||
|
$url = rtrim($base, '/') . '/' . ltrim($endpoint, '/');
|
||||||
|
|
||||||
|
$timeout = (int) config('vision.yolo.timeout_seconds', 8);
|
||||||
|
$connectTimeout = (int) config('vision.yolo.connect_timeout_seconds', 2);
|
||||||
|
$retries = (int) config('vision.yolo.retries', 1);
|
||||||
|
$delay = (int) config('vision.yolo.retry_delay_ms', 200);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::acceptJson()
|
||||||
|
->connectTimeout(max(1, $connectTimeout))
|
||||||
|
->timeout(max(1, $timeout))
|
||||||
|
->retry(max(0, $retries), max(0, $delay), throw: false)
|
||||||
|
->post($url, [
|
||||||
|
'image_url' => $imageUrl,
|
||||||
|
'artwork_id' => $this->artworkId,
|
||||||
|
'hash' => $this->hash,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('YOLO analyze request failed', ['ref' => $ref, 'artwork_id' => $this->artworkId, 'error' => $e->getMessage()]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->serverError()) {
|
||||||
|
Log::warning('YOLO analyze server error', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]);
|
||||||
|
throw new \RuntimeException('YOLO server error: ' . $response->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $response->ok()) {
|
||||||
|
Log::warning('YOLO analyze non-ok response', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->extractTagList($response->json());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldRunYolo(Artwork $artwork): bool
|
||||||
|
{
|
||||||
|
if (! (bool) config('vision.yolo.enabled', true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! (bool) config('vision.yolo.photography_only', true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($artwork->categories as $category) {
|
||||||
|
$slug = strtolower((string) ($category->contentType?->slug ?? ''));
|
||||||
|
if ($slug === 'photography') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $json
|
||||||
|
* @return array<int, array{tag: string, confidence?: float|int|null}>
|
||||||
|
*/
|
||||||
|
private function extractTagList(mixed $json): array
|
||||||
|
{
|
||||||
|
if (is_array($json) && $this->isListOfTags($json)) {
|
||||||
|
return $json;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($json) && isset($json['tags']) && is_array($json['tags']) && $this->isListOfTags($json['tags'])) {
|
||||||
|
return $json['tags'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($json) && isset($json['data']) && is_array($json['data']) && $this->isListOfTags($json['data'])) {
|
||||||
|
return $json['data'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common YOLO-style response: objects: [{label, confidence}]
|
||||||
|
if (is_array($json) && isset($json['objects']) && is_array($json['objects'])) {
|
||||||
|
$out = [];
|
||||||
|
foreach ($json['objects'] as $obj) {
|
||||||
|
if (! is_array($obj)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$label = (string) ($obj['label'] ?? $obj['tag'] ?? '');
|
||||||
|
if ($label === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[] = ['tag' => $label, 'confidence' => $obj['confidence'] ?? null];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<mixed> $arr
|
||||||
|
*/
|
||||||
|
private function isListOfTags(array $arr): bool
|
||||||
|
{
|
||||||
|
if ($arr === []) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($arr as $row) {
|
||||||
|
if (! is_array($row)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (! array_key_exists('tag', $row)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{tag: string, confidence?: float|int|null}> $a
|
||||||
|
* @param array<int, array{tag: string, confidence?: float|int|null}> $b
|
||||||
|
* @return array<int, array{tag: string, confidence?: float|int|null}>
|
||||||
|
*/
|
||||||
|
private function mergeTags(array $a, array $b): array
|
||||||
|
{
|
||||||
|
$byTag = [];
|
||||||
|
foreach (array_merge($a, $b) as $row) {
|
||||||
|
$tag = (string) ($row['tag'] ?? '');
|
||||||
|
if ($tag === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$conf = $row['confidence'] ?? null;
|
||||||
|
$conf = is_numeric($conf) ? (float) $conf : null;
|
||||||
|
|
||||||
|
// Keep highest confidence for duplicates.
|
||||||
|
if (! isset($byTag[$tag])) {
|
||||||
|
$byTag[$tag] = ['tag' => $tag, 'confidence' => $conf];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $byTag[$tag]['confidence'];
|
||||||
|
if ($existing === null || ($conf !== null && $conf > (float) $existing)) {
|
||||||
|
$byTag[$tag]['confidence'] = $conf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($byTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processingKey(int $artworkId, string $hash): string
|
||||||
|
{
|
||||||
|
return 'autotag:processing:' . $artworkId . ':' . $hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processedKey(int $artworkId, string $hash): string
|
||||||
|
{
|
||||||
|
return 'autotag:processed:' . $artworkId . ':' . $hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function acquireProcessingLock(string $key): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$didSet = Redis::setnx($key, 1);
|
||||||
|
if ($didSet) {
|
||||||
|
Redis::expire($key, 1800);
|
||||||
|
}
|
||||||
|
return (bool) $didSet;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// If Redis is unavailable, proceed without dedupe.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function releaseProcessingLock(string $key): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
Redis::del($key);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function markProcessed(string $key): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
Redis::setex($key, 604800, 1); // 7 days
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeBody(string $body): string
|
||||||
|
{
|
||||||
|
$body = trim($body);
|
||||||
|
if ($body === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Str::limit($body, 800);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Jobs/BackfillArtworkEmbeddingsJob.php
Normal file
61
app/Jobs/BackfillArtworkEmbeddingsJob.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Jobs\GenerateArtworkEmbeddingJob;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
final class BackfillArtworkEmbeddingsJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
|
public int $timeout = 120;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $afterId = 0,
|
||||||
|
private readonly int $batchSize = 200,
|
||||||
|
private readonly bool $force = false,
|
||||||
|
) {
|
||||||
|
$queue = (string) config('recommendations.queue', config('vision.queue', 'default'));
|
||||||
|
if ($queue !== '') {
|
||||||
|
$this->onQueue($queue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$batch = max(1, min($this->batchSize, 1000));
|
||||||
|
|
||||||
|
$artworks = Artwork::query()
|
||||||
|
->where('id', '>', $this->afterId)
|
||||||
|
->whereNotNull('hash')
|
||||||
|
->orderBy('id')
|
||||||
|
->limit($batch)
|
||||||
|
->get(['id', 'hash']);
|
||||||
|
|
||||||
|
if ($artworks->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($artworks as $artwork) {
|
||||||
|
GenerateArtworkEmbeddingJob::dispatch((int) $artwork->id, (string) $artwork->hash, $this->force);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($artworks->count() === $batch) {
|
||||||
|
$lastId = (int) $artworks->last()->id;
|
||||||
|
self::dispatch($lastId, $batch, $this->force);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
178
app/Jobs/GenerateArtworkEmbeddingJob.php
Normal file
178
app/Jobs/GenerateArtworkEmbeddingJob.php
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\ArtworkEmbedding;
|
||||||
|
use App\Services\Vision\ArtworkEmbeddingClient;
|
||||||
|
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\Redis;
|
||||||
|
|
||||||
|
final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
public int $timeout = 20;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $artworkId,
|
||||||
|
private readonly ?string $sourceHash = null,
|
||||||
|
private readonly bool $force = false,
|
||||||
|
) {
|
||||||
|
$queue = (string) config('recommendations.queue', config('vision.queue', 'default'));
|
||||||
|
if ($queue !== '') {
|
||||||
|
$this->onQueue($queue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function backoff(): array
|
||||||
|
{
|
||||||
|
return [2, 10, 30];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(ArtworkEmbeddingClient $client): void
|
||||||
|
{
|
||||||
|
if (! (bool) config('recommendations.embedding.enabled', true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$artwork = Artwork::query()->find($this->artworkId);
|
||||||
|
if (! $artwork) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceHash = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) ($this->sourceHash ?? $artwork->hash ?? '')));
|
||||||
|
if ($sourceHash === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$model = (string) config('recommendations.embedding.model', 'clip');
|
||||||
|
$modelVersion = (string) config('recommendations.embedding.model_version', 'v1');
|
||||||
|
$algoVersion = (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1');
|
||||||
|
|
||||||
|
if (! $this->force) {
|
||||||
|
$existing = ArtworkEmbedding::query()
|
||||||
|
->where('artwork_id', $artwork->id)
|
||||||
|
->where('model', $model)
|
||||||
|
->where('model_version', $modelVersion)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing && (string) ($existing->source_hash ?? '') === $sourceHash) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$lockKey = $this->lockKey($artwork->id, $model, $modelVersion);
|
||||||
|
if (! $this->acquireLock($lockKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$imageUrl = $this->buildImageUrl($sourceHash);
|
||||||
|
if ($imageUrl === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$vector = $client->embed($imageUrl, (int) $artwork->id, $sourceHash);
|
||||||
|
if ($vector === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = $this->normalize($vector);
|
||||||
|
|
||||||
|
ArtworkEmbedding::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'artwork_id' => (int) $artwork->id,
|
||||||
|
'model' => $model,
|
||||||
|
'model_version' => $modelVersion,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'algo_version' => $algoVersion,
|
||||||
|
'dim' => count($normalized),
|
||||||
|
'embedding_json' => json_encode($normalized, JSON_THROW_ON_ERROR),
|
||||||
|
'source_hash' => $sourceHash,
|
||||||
|
'is_normalized' => true,
|
||||||
|
'generated_at' => now(),
|
||||||
|
'meta' => [
|
||||||
|
'source' => 'clip',
|
||||||
|
'image_variant' => (string) config('vision.image_variant', 'md'),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
$this->releaseLock($lockKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, float> $vector
|
||||||
|
* @return array<int, float>
|
||||||
|
*/
|
||||||
|
private function normalize(array $vector): array
|
||||||
|
{
|
||||||
|
$sumSquares = 0.0;
|
||||||
|
foreach ($vector as $value) {
|
||||||
|
$sumSquares += ($value * $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sumSquares <= 0.0) {
|
||||||
|
return $vector;
|
||||||
|
}
|
||||||
|
|
||||||
|
$norm = sqrt($sumSquares);
|
||||||
|
return array_map(static fn (float $value): float => $value / $norm, $vector);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildImageUrl(string $hash): ?string
|
||||||
|
{
|
||||||
|
$base = rtrim((string) config('cdn.files_url', ''), '/');
|
||||||
|
if ($base === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant = (string) config('vision.image_variant', 'md');
|
||||||
|
$clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash));
|
||||||
|
$clean = str_pad($clean, 6, '0');
|
||||||
|
$segments = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00'];
|
||||||
|
|
||||||
|
return $base . '/img/' . implode('/', $segments) . '/' . $variant . '.webp';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lockKey(int $artworkId, string $model, string $version): string
|
||||||
|
{
|
||||||
|
return 'artwork-embedding:lock:' . $artworkId . ':' . $model . ':' . $version;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function acquireLock(string $key): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$didSet = Redis::setnx($key, 1);
|
||||||
|
if ($didSet) {
|
||||||
|
Redis::expire($key, 1800);
|
||||||
|
}
|
||||||
|
return (bool) $didSet;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function releaseLock(string $key): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
Redis::del($key);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Jobs/GenerateDerivativesJob.php
Normal file
38
app/Jobs/GenerateDerivativesJob.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Services\Uploads\UploadPipelineService;
|
||||||
|
use App\Jobs\AutoTagArtworkJob;
|
||||||
|
use App\Jobs\GenerateArtworkEmbeddingJob;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
final class GenerateDerivativesJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $sessionId,
|
||||||
|
private readonly string $hash,
|
||||||
|
private readonly int $artworkId
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(UploadPipelineService $pipeline): void
|
||||||
|
{
|
||||||
|
$pipeline->processAndPublish($this->sessionId, $this->hash, $this->artworkId);
|
||||||
|
|
||||||
|
// Auto-tagging is async and must never block publish.
|
||||||
|
AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit();
|
||||||
|
GenerateArtworkEmbeddingJob::dispatch($this->artworkId, $this->hash)->afterCommit();
|
||||||
|
}
|
||||||
|
}
|
||||||
122
app/Jobs/IngestUserDiscoveryEventJob.php
Normal file
122
app/Jobs/IngestUserDiscoveryEventJob.php
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Services\Recommendations\UserInterestProfileService;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
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\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
final class IngestUserDiscoveryEventJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
/** @var array<int, int> */
|
||||||
|
public array $backoff = [5, 30, 120];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $meta
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $eventId,
|
||||||
|
public readonly int $userId,
|
||||||
|
public readonly int $artworkId,
|
||||||
|
public readonly string $eventType,
|
||||||
|
public readonly string $algoVersion,
|
||||||
|
public readonly string $occurredAt,
|
||||||
|
public readonly array $meta = []
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(UserInterestProfileService $profileService): void
|
||||||
|
{
|
||||||
|
$idempotencyKey = sprintf('discovery:event:processed:%s', $this->eventId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$didSet = false;
|
||||||
|
try {
|
||||||
|
$didSet = (bool) Redis::setnx($idempotencyKey, 1);
|
||||||
|
if ($didSet) {
|
||||||
|
Redis::expire($idempotencyKey, 86400 * 2);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Redis unavailable for discovery ingestion; proceeding without redis dedupe', [
|
||||||
|
'event_id' => $this->eventId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
$didSet = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $didSet) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$occurredAt = CarbonImmutable::parse($this->occurredAt);
|
||||||
|
$eventVersion = (string) config('discovery.event_version', 'event-v1');
|
||||||
|
$eventWeight = (float) ((array) config('discovery.weights', []))[$this->eventType] ?? 1.0;
|
||||||
|
|
||||||
|
$categoryId = DB::table('artwork_category')
|
||||||
|
->where('artwork_id', $this->artworkId)
|
||||||
|
->orderBy('category_id')
|
||||||
|
->value('category_id');
|
||||||
|
|
||||||
|
$insertPayload = [
|
||||||
|
'event_id' => $this->eventId,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'artwork_id' => $this->artworkId,
|
||||||
|
'category_id' => $categoryId !== null ? (int) $categoryId : null,
|
||||||
|
'event_type' => $this->eventType,
|
||||||
|
'event_version' => $eventVersion,
|
||||||
|
'algo_version' => $this->algoVersion,
|
||||||
|
'weight' => $eventWeight,
|
||||||
|
'event_date' => $occurredAt->toDateString(),
|
||||||
|
'occurred_at' => $occurredAt->toDateTimeString(),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (Schema::hasColumn('user_discovery_events', 'meta')) {
|
||||||
|
$insertPayload['meta'] = $this->meta;
|
||||||
|
} elseif (Schema::hasColumn('user_discovery_events', 'metadata')) {
|
||||||
|
$insertPayload['metadata'] = json_encode($this->meta, JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('user_discovery_events')->insertOrIgnore($insertPayload);
|
||||||
|
|
||||||
|
$profileService->applyEvent(
|
||||||
|
userId: $this->userId,
|
||||||
|
eventType: $this->eventType,
|
||||||
|
artworkId: $this->artworkId,
|
||||||
|
categoryId: $categoryId !== null ? (int) $categoryId : null,
|
||||||
|
occurredAt: $occurredAt,
|
||||||
|
eventId: $this->eventId,
|
||||||
|
algoVersion: $this->algoVersion,
|
||||||
|
eventMeta: $this->meta
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('IngestUserDiscoveryEventJob failed', [
|
||||||
|
'event_id' => $this->eventId,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'artwork_id' => $this->artworkId,
|
||||||
|
'event_type' => $this->eventType,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Jobs/RegenerateUserRecommendationCacheJob.php
Normal file
47
app/Jobs/RegenerateUserRecommendationCacheJob.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Services\Recommendations\PersonalizedFeedService;
|
||||||
|
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;
|
||||||
|
|
||||||
|
final class RegenerateUserRecommendationCacheJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
/** @var array<int, int> */
|
||||||
|
public array $backoff = [10, 60, 180];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $userId,
|
||||||
|
public readonly string $algoVersion
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(PersonalizedFeedService $feedService): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$feedService->regenerateCacheForUser($this->userId, $this->algoVersion);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('RegenerateUserRecommendationCacheJob failed', [
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'algo_version' => $this->algoVersion,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
use App\Services\ThumbnailService;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App\Models\Artwork
|
* App\Models\Artwork
|
||||||
@@ -74,13 +76,8 @@ class Artwork extends Model
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$size = array_key_exists($size, self::THUMB_SIZES) ? $size : 'md';
|
$sizeKey = array_key_exists($size, self::THUMB_SIZES) ? $size : 'md';
|
||||||
$h = $this->hash;
|
return ThumbnailService::fromHash($this->hash, $this->thumb_ext, $sizeKey);
|
||||||
$h1 = substr($h, 0, 2);
|
|
||||||
$h2 = substr($h, 2, 2);
|
|
||||||
$ext = $this->thumb_ext;
|
|
||||||
|
|
||||||
return "https://files.skinbase.org/{$size}/{$h1}/{$h2}/{$h}.{$ext}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,6 +96,19 @@ class Artwork extends Model
|
|||||||
return $this->thumbUrl('md');
|
return $this->thumbUrl('md');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backwards-compatible alias used by legacy views: `$art->thumbnail_url`.
|
||||||
|
* Prefer CDN thumbnail URL, then legacy `thumb` accessor, finally a placeholder.
|
||||||
|
*/
|
||||||
|
public function getThumbnailUrlAttribute(): ?string
|
||||||
|
{
|
||||||
|
$url = $this->getThumbUrlAttribute();
|
||||||
|
if (!empty($url)) return $url;
|
||||||
|
$thumb = $this->getThumbAttribute();
|
||||||
|
if (!empty($thumb)) return $thumb;
|
||||||
|
return '/images/placeholder.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provide a responsive `srcset` for legacy views.
|
* Provide a responsive `srcset` for legacy views.
|
||||||
*/
|
*/
|
||||||
@@ -132,6 +142,12 @@ class Artwork extends Model
|
|||||||
return $this->belongsToMany(Category::class, 'artwork_category', 'artwork_id', 'category_id');
|
return $this->belongsToMany(Category::class, 'artwork_category', 'artwork_id', 'category_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function tags(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Tag::class, 'artwork_tag', 'artwork_id', 'tag_id')
|
||||||
|
->withPivot(['source', 'confidence']);
|
||||||
|
}
|
||||||
|
|
||||||
public function comments(): HasMany
|
public function comments(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(ArtworkComment::class);
|
return $this->hasMany(ArtworkComment::class);
|
||||||
@@ -142,6 +158,16 @@ class Artwork extends Model
|
|||||||
return $this->hasMany(ArtworkDownload::class);
|
return $this->hasMany(ArtworkDownload::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function embeddings(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ArtworkEmbedding::class, 'artwork_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function similarities(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ArtworkSimilarity::class, 'artwork_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function features(): HasMany
|
public function features(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(ArtworkFeature::class, 'artwork_id');
|
return $this->hasMany(ArtworkFeature::class, 'artwork_id');
|
||||||
@@ -175,4 +201,24 @@ class Artwork extends Model
|
|||||||
{
|
{
|
||||||
return 'slug';
|
return 'slug';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::deleting(function (Artwork $artwork): void {
|
||||||
|
if (! method_exists($artwork, 'isForceDeleting') || ! $artwork->isForceDeleting()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup pivot rows and decrement usage counts on force delete.
|
||||||
|
$tagIds = DB::table('artwork_tag')->where('artwork_id', $artwork->id)->pluck('tag_id')->all();
|
||||||
|
if ($tagIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('artwork_tag')->where('artwork_id', $artwork->id)->delete();
|
||||||
|
DB::table('tags')
|
||||||
|
->whereIn('id', $tagIds)
|
||||||
|
->update(['usage_count' => DB::raw('CASE WHEN usage_count > 0 THEN usage_count - 1 ELSE 0 END')]);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
app/Models/ArtworkEmbedding.php
Normal file
37
app/Models/ArtworkEmbedding.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
final class ArtworkEmbedding extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'artwork_embeddings';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'artwork_id',
|
||||||
|
'model',
|
||||||
|
'model_version',
|
||||||
|
'algo_version',
|
||||||
|
'dim',
|
||||||
|
'embedding_json',
|
||||||
|
'source_hash',
|
||||||
|
'is_normalized',
|
||||||
|
'generated_at',
|
||||||
|
'meta',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_normalized' => 'boolean',
|
||||||
|
'generated_at' => 'datetime',
|
||||||
|
'meta' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function artwork(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Artwork::class, 'artwork_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Models/ArtworkSimilarity.php
Normal file
42
app/Models/ArtworkSimilarity.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
final class ArtworkSimilarity extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'artwork_similarities';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'artwork_id',
|
||||||
|
'similar_artwork_id',
|
||||||
|
'model',
|
||||||
|
'model_version',
|
||||||
|
'algo_version',
|
||||||
|
'rank',
|
||||||
|
'score',
|
||||||
|
'generated_at',
|
||||||
|
'meta',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'rank' => 'integer',
|
||||||
|
'score' => 'float',
|
||||||
|
'generated_at' => 'datetime',
|
||||||
|
'meta' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function artwork(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Artwork::class, 'artwork_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function similarArtwork(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Artwork::class, 'similar_artwork_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,4 +113,50 @@ class Category extends Model
|
|||||||
{
|
{
|
||||||
return 'slug';
|
return 'slug';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a category by a content-type slug and a category path (e.g. "audio/winamp").
|
||||||
|
* This will locate the category with the final slug and verify its parent chain
|
||||||
|
* matches the provided path and that the category belongs to the given content type.
|
||||||
|
*
|
||||||
|
* @param string $contentTypeSlug
|
||||||
|
* @param string|array $categoryPath
|
||||||
|
* @return Category|null
|
||||||
|
*/
|
||||||
|
public static function findByPath(string $contentTypeSlug, $categoryPath): ?Category
|
||||||
|
{
|
||||||
|
$parts = is_array($categoryPath)
|
||||||
|
? array_values(array_map('strtolower', array_filter($categoryPath)))
|
||||||
|
: array_values(array_map('strtolower', array_filter(explode('/', (string) $categoryPath))));
|
||||||
|
|
||||||
|
if (empty($parts)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$last = end($parts);
|
||||||
|
|
||||||
|
$category = static::where('slug', $last)
|
||||||
|
->whereHas('contentType', function ($q) use ($contentTypeSlug) {
|
||||||
|
$q->where('slug', strtolower($contentTypeSlug));
|
||||||
|
})
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $category) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify parent chain matches the preceding parts in the path
|
||||||
|
$idx = count($parts) - 2;
|
||||||
|
$current = $category;
|
||||||
|
while ($idx >= 0) {
|
||||||
|
$parent = $current->parent;
|
||||||
|
if (! $parent || $parent->slug !== $parts[$idx]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$current = $parent;
|
||||||
|
$idx--;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $category;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
|
||||||
class ContentType extends Model
|
class ContentType extends Model
|
||||||
{
|
{
|
||||||
@@ -19,6 +22,18 @@ class ContentType extends Model
|
|||||||
return $this->categories()->whereNull('parent_id');
|
return $this->categories()->whereNull('parent_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return an Eloquent builder for Artworks that belong to this content type.
|
||||||
|
* This traverses the pivot `artwork_category` via the `categories` relation.
|
||||||
|
* Note: not a direct Eloquent relation (uses whereHas) so it can be queried/eager-loaded manually.
|
||||||
|
*/
|
||||||
|
public function artworks(): EloquentBuilder
|
||||||
|
{
|
||||||
|
return Artwork::whereHas('categories', function ($q) {
|
||||||
|
$q->where('content_type_id', $this->id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function getRouteKeyName(): string
|
public function getRouteKeyName(): string
|
||||||
{
|
{
|
||||||
return 'slug';
|
return 'slug';
|
||||||
|
|||||||
39
app/Models/Tag.php
Normal file
39
app/Models/Tag.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
|
||||||
|
final class Tag extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'tags';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'slug',
|
||||||
|
'usage_count',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'usage_count' => 'integer',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function artworks(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Artwork::class, 'artwork_tag', 'tag_id', 'artwork_id')
|
||||||
|
->withPivot(['source', 'confidence']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'slug';
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Models/Upload.php
Normal file
51
app/Models/Upload.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Upload extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'uploads';
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'id',
|
||||||
|
'user_id',
|
||||||
|
'type',
|
||||||
|
'status',
|
||||||
|
'processing_state',
|
||||||
|
'title',
|
||||||
|
'slug',
|
||||||
|
'category_id',
|
||||||
|
'description',
|
||||||
|
'tags',
|
||||||
|
'license',
|
||||||
|
'nsfw',
|
||||||
|
'is_scanned',
|
||||||
|
'has_tags',
|
||||||
|
'preview_path',
|
||||||
|
'published_at',
|
||||||
|
'final_path',
|
||||||
|
'expires_at',
|
||||||
|
'moderation_status',
|
||||||
|
'moderated_at',
|
||||||
|
'moderated_by',
|
||||||
|
'moderation_note',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'tags' => 'array',
|
||||||
|
'nsfw' => 'boolean',
|
||||||
|
'is_scanned' => 'boolean',
|
||||||
|
'has_tags' => 'boolean',
|
||||||
|
'published_at' => 'datetime',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'moderated_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ class User extends Authenticatable
|
|||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
|
'role',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,4 +54,19 @@ class User extends Authenticatable
|
|||||||
{
|
{
|
||||||
return $this->hasMany(Artwork::class);
|
return $this->hasMany(Artwork::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hasRole(string $role): bool
|
||||||
|
{
|
||||||
|
return strtolower((string) ($this->role ?? '')) === strtolower($role);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAdmin(): bool
|
||||||
|
{
|
||||||
|
return $this->hasRole('admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isModerator(): bool
|
||||||
|
{
|
||||||
|
return $this->hasRole('moderator');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
app/Models/UserDiscoveryEvent.php
Normal file
39
app/Models/UserDiscoveryEvent.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class UserDiscoveryEvent extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'user_discovery_events';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'occurred_at' => 'datetime',
|
||||||
|
'meta' => 'array',
|
||||||
|
'weight' => 'float',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function artwork(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Artwork::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function category(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Category::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Models/UserInterestProfile.php
Normal file
31
app/Models/UserInterestProfile.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class UserInterestProfile extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'user_interest_profiles';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'raw_scores_json' => 'array',
|
||||||
|
'normalized_scores_json' => 'array',
|
||||||
|
'last_event_at' => 'datetime',
|
||||||
|
'half_life_hours' => 'float',
|
||||||
|
'total_weight' => 'float',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/Models/UserProfile.php
Normal file
69
app/Models/UserProfile.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class UserProfile extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'user_profiles';
|
||||||
|
protected $primaryKey = 'user_id';
|
||||||
|
public $incrementing = false;
|
||||||
|
protected $keyType = 'int';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'about',
|
||||||
|
'signature',
|
||||||
|
'description',
|
||||||
|
'avatar',
|
||||||
|
'avatar_hash',
|
||||||
|
'avatar_mime',
|
||||||
|
'avatar_updated_at',
|
||||||
|
'cover_image',
|
||||||
|
'country',
|
||||||
|
'country_code',
|
||||||
|
'language',
|
||||||
|
'birthdate',
|
||||||
|
'gender',
|
||||||
|
'website',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'birthdate' => 'date',
|
||||||
|
'avatar_updated_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public $timestamps = true;
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a public URL for the avatar when stored on the `public` disk under `avatars/`.
|
||||||
|
*/
|
||||||
|
public function getAvatarUrlAttribute(): ?string
|
||||||
|
{
|
||||||
|
if (empty($this->avatar)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the stored value already looks like a full URL, return it.
|
||||||
|
if (preg_match('#^https?://#i', $this->avatar)) {
|
||||||
|
return $this->avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer `public` disk and avatars folder.
|
||||||
|
$path = 'avatars/' . ltrim($this->avatar, '/');
|
||||||
|
if (Storage::disk('public')->exists($path)) {
|
||||||
|
return Storage::disk('public')->url($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: return null if not found
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Models/UserRecommendationCache.php
Normal file
29
app/Models/UserRecommendationCache.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class UserRecommendationCache extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'user_recommendation_cache';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'recommendations_json' => 'array',
|
||||||
|
'generated_at' => 'datetime',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,25 @@ class ArtworkPolicy
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function isModerator(User $user): bool
|
||||||
|
{
|
||||||
|
foreach (['is_moderator', 'is_mod', 'moderator'] as $prop) {
|
||||||
|
if (isset($user->{$prop})) {
|
||||||
|
return (bool) $user->{$prop};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method_exists($user, 'hasRole')) {
|
||||||
|
return (bool) ($user->hasRole('moderator') || $user->hasRole('mod'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method_exists($user, 'isModerator')) {
|
||||||
|
return (bool) $user->isModerator();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public view: only approved + public + not-deleted artworks.
|
* Public view: only approved + public + not-deleted artworks.
|
||||||
*/
|
*/
|
||||||
@@ -64,6 +83,14 @@ class ArtworkPolicy
|
|||||||
return $user->id === $artwork->user_id;
|
return $user->id === $artwork->user_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag edits: owner or moderator or admin (admin handled by before()).
|
||||||
|
*/
|
||||||
|
public function updateTags(User $user, Artwork $artwork): bool
|
||||||
|
{
|
||||||
|
return $user->id === $artwork->user_id || $this->isModerator($user);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owner can delete their own artwork (soft delete).
|
* Owner can delete their own artwork (soft delete).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
|
||||||
|
use App\Services\Upload\UploadDraftService;
|
||||||
use Illuminate\Support\Facades\View;
|
use Illuminate\Support\Facades\View;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -14,7 +19,10 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
//
|
// Bind UploadDraftService interface to implementation
|
||||||
|
$this->app->singleton(UploadDraftServiceInterface::class, function ($app) {
|
||||||
|
return new UploadDraftService($app->make('filesystem'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,11 +30,14 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
$this->configureUploadRateLimiters();
|
||||||
|
|
||||||
// Provide toolbar counts and user info to layout views (port of legacy toolbar logic)
|
// Provide toolbar counts and user info to layout views (port of legacy toolbar logic)
|
||||||
View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) {
|
View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) {
|
||||||
$uploadCount = $favCount = $msgCount = $noticeCount = 0;
|
$uploadCount = $favCount = $msgCount = $noticeCount = 0;
|
||||||
$avatar = null;
|
$avatar = null;
|
||||||
$displayName = null;
|
$displayName = null;
|
||||||
|
$userId = null;
|
||||||
|
|
||||||
if (Auth::check()) {
|
if (Auth::check()) {
|
||||||
$userId = Auth::id();
|
$userId = Auth::id();
|
||||||
@@ -72,4 +83,40 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
$view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'avatar', 'displayName'));
|
$view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'avatar', 'displayName'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function configureUploadRateLimiters(): void
|
||||||
|
{
|
||||||
|
RateLimiter::for('uploads-init', function (Request $request): array {
|
||||||
|
return $this->buildUploadLimits($request, 'init');
|
||||||
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('uploads-finish', function (Request $request): array {
|
||||||
|
return $this->buildUploadLimits($request, 'finish');
|
||||||
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('uploads-status', function (Request $request): array {
|
||||||
|
return $this->buildUploadLimits($request, 'status');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildUploadLimits(Request $request, string $key): array
|
||||||
|
{
|
||||||
|
$config = (array) config('uploads.rate_limits.' . $key, []);
|
||||||
|
$decay = (int) config('uploads.rate_limits.decay_minutes', 1);
|
||||||
|
$perUser = (int) ($config['per_user'] ?? 0);
|
||||||
|
$perIp = (int) ($config['per_ip'] ?? 0);
|
||||||
|
|
||||||
|
$limits = [];
|
||||||
|
|
||||||
|
if ($perUser > 0) {
|
||||||
|
$userId = $request->user()?->id ?? 'guest';
|
||||||
|
$limits[] = Limit::perMinutes($decay, $perUser)->by('u:' . $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($perIp > 0) {
|
||||||
|
$limits[] = Limit::perMinutes($decay, $perIp)->by('ip:' . $request->ip());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $limits;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
app/Repositories/Uploads/ArtworkFileRepository.php
Normal file
18
app/Repositories/Uploads/ArtworkFileRepository.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repositories\Uploads;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class ArtworkFileRepository
|
||||||
|
{
|
||||||
|
public function upsert(int $artworkId, string $variant, string $path, string $mime, int $size): void
|
||||||
|
{
|
||||||
|
DB::table('artwork_files')->updateOrInsert(
|
||||||
|
['artwork_id' => $artworkId, 'variant' => $variant],
|
||||||
|
['path' => $path, 'mime' => $mime, 'size' => $size]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Repositories/Uploads/AuditLogRepository.php
Normal file
21
app/Repositories/Uploads/AuditLogRepository.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repositories\Uploads;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class AuditLogRepository
|
||||||
|
{
|
||||||
|
public function log(?int $userId, string $action, string $ip, array $meta = []): void
|
||||||
|
{
|
||||||
|
DB::table('audit_logs')->insert([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'action' => $action,
|
||||||
|
'ip' => $ip,
|
||||||
|
'meta' => empty($meta) ? null : json_encode($meta, JSON_THROW_ON_ERROR),
|
||||||
|
'created_at' => now()->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
113
app/Repositories/Uploads/UploadSessionRepository.php
Normal file
113
app/Repositories/Uploads/UploadSessionRepository.php
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repositories\Uploads;
|
||||||
|
|
||||||
|
use App\DTOs\Uploads\UploadSessionData;
|
||||||
|
use App\Services\Uploads\UploadSessionStatus;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class UploadSessionRepository
|
||||||
|
{
|
||||||
|
public function create(string $id, int $userId, string $tempPath, string $status, string $ip): UploadSessionData
|
||||||
|
{
|
||||||
|
$createdAt = CarbonImmutable::now();
|
||||||
|
|
||||||
|
DB::table('uploads_sessions')->insert([
|
||||||
|
'id' => $id,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'temp_path' => $tempPath,
|
||||||
|
'status' => $status,
|
||||||
|
'ip' => $ip,
|
||||||
|
'created_at' => $createdAt->toDateTimeString(),
|
||||||
|
'progress' => 0,
|
||||||
|
'failure_reason' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new UploadSessionData($id, $userId, $tempPath, $status, $ip, $createdAt, 0, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $id): ?UploadSessionData
|
||||||
|
{
|
||||||
|
$row = DB::table('uploads_sessions')->where('id', $id)->first();
|
||||||
|
|
||||||
|
if (! $row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UploadSessionData(
|
||||||
|
(string) $row->id,
|
||||||
|
(int) $row->user_id,
|
||||||
|
(string) $row->temp_path,
|
||||||
|
(string) $row->status,
|
||||||
|
(string) $row->ip,
|
||||||
|
CarbonImmutable::parse($row->created_at),
|
||||||
|
(int) ($row->progress ?? 0),
|
||||||
|
$row->failure_reason ? (string) $row->failure_reason : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrFail(string $id): UploadSessionData
|
||||||
|
{
|
||||||
|
$session = $this->get($id);
|
||||||
|
|
||||||
|
if (! $session) {
|
||||||
|
throw new RuntimeException('Upload session not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateStatus(string $id, string $status): void
|
||||||
|
{
|
||||||
|
DB::table('uploads_sessions')->where('id', $id)->update([
|
||||||
|
'status' => $status,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateProgress(string $id, int $progress): void
|
||||||
|
{
|
||||||
|
DB::table('uploads_sessions')->where('id', $id)->update([
|
||||||
|
'progress' => max(0, min(100, $progress)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateFailureReason(string $id, ?string $reason): void
|
||||||
|
{
|
||||||
|
DB::table('uploads_sessions')->where('id', $id)->update([
|
||||||
|
'failure_reason' => $reason,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateTempPath(string $id, string $tempPath): void
|
||||||
|
{
|
||||||
|
DB::table('uploads_sessions')->where('id', $id)->update([
|
||||||
|
'temp_path' => $tempPath,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countActiveForUser(int $userId): int
|
||||||
|
{
|
||||||
|
$terminal = [
|
||||||
|
UploadSessionStatus::PROCESSED,
|
||||||
|
UploadSessionStatus::QUARANTINED,
|
||||||
|
UploadSessionStatus::CANCELLED,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (int) DB::table('uploads_sessions')
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->whereNotIn('status', $terminal)
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countForUserSince(int $userId, CarbonImmutable $since): int
|
||||||
|
{
|
||||||
|
return (int) DB::table('uploads_sessions')
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('created_at', '>=', $since->toDateTimeString())
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Services/Artworks/ArtworkDraftService.php
Normal file
55
app/Services/Artworks/ArtworkDraftService.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Artworks;
|
||||||
|
|
||||||
|
use App\DTOs\Artworks\ArtworkDraftResult;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
final class ArtworkDraftService
|
||||||
|
{
|
||||||
|
public function createDraft(int $userId, string $title, ?string $description): ArtworkDraftResult
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($userId, $title, $description) {
|
||||||
|
$slug = $this->uniqueSlug($title);
|
||||||
|
|
||||||
|
$artwork = Artwork::create([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'title' => $title,
|
||||||
|
'slug' => $slug,
|
||||||
|
'description' => $description,
|
||||||
|
'file_name' => 'pending',
|
||||||
|
'file_path' => 'pending',
|
||||||
|
'file_size' => 0,
|
||||||
|
'mime_type' => 'application/octet-stream',
|
||||||
|
'width' => 1,
|
||||||
|
'height' => 1,
|
||||||
|
'is_public' => false,
|
||||||
|
'is_approved' => false,
|
||||||
|
'published_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new ArtworkDraftResult((int) $artwork->id, 'draft');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function uniqueSlug(string $title): string
|
||||||
|
{
|
||||||
|
$base = Str::slug($title);
|
||||||
|
$base = $base !== '' ? $base : 'artwork';
|
||||||
|
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
$suffix = Str::lower(Str::random(6));
|
||||||
|
$slug = Str::limit($base . '-' . $suffix, 160, '');
|
||||||
|
|
||||||
|
if (! Artwork::where('slug', $slug)->exists()) {
|
||||||
|
return $slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Str::limit($base . '-' . Str::uuid()->toString(), 160, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
169
app/Services/AvatarService.php
Normal file
169
app/Services/AvatarService.php
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Intervention\Image\ImageManagerStatic as Image;
|
||||||
|
use RuntimeException;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
|
||||||
|
class AvatarService
|
||||||
|
{
|
||||||
|
protected $sizes = [
|
||||||
|
'xs' => 32,
|
||||||
|
'sm' => 64,
|
||||||
|
'md' => 128,
|
||||||
|
'lg' => 256,
|
||||||
|
'xl' => 512,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $quality = 85;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
// Guard: if Intervention Image is not installed, defer error until actual use
|
||||||
|
if (class_exists(\Intervention\Image\ImageManagerStatic::class)) {
|
||||||
|
try {
|
||||||
|
Image::configure(['driver' => extension_loaded('gd') ? 'gd' : 'imagick']);
|
||||||
|
$this->imageAvailable = true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// If configuration fails, treat as unavailable and log for diagnostics
|
||||||
|
logger()->warning('Intervention Image present but configuration failed: '.$e->getMessage());
|
||||||
|
$this->imageAvailable = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->imageAvailable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an uploaded file for a user and store webp sizes.
|
||||||
|
* Returns the computed sha1 hash.
|
||||||
|
*
|
||||||
|
* @param int $userId
|
||||||
|
* @param UploadedFile $file
|
||||||
|
* @return string sha1 hash
|
||||||
|
*/
|
||||||
|
public function storeFromUploadedFile(int $userId, UploadedFile $file): string
|
||||||
|
{
|
||||||
|
if (! $this->imageAvailable) {
|
||||||
|
throw new RuntimeException('Intervention Image is not available. If you just installed the package, restart your PHP process (php artisan serve or PHP-FPM) and run `composer dump-autoload -o`.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load image and re-encode to webp after validating
|
||||||
|
try {
|
||||||
|
$img = Image::make($file->getRealPath());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
throw new RuntimeException('Failed to read uploaded image: '.$e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure square center crop per spec
|
||||||
|
$max = max($img->width(), $img->height());
|
||||||
|
$img->fit($max, $max);
|
||||||
|
|
||||||
|
$basePath = "avatars/{$userId}";
|
||||||
|
Storage::disk('public')->makeDirectory($basePath);
|
||||||
|
|
||||||
|
// Save original as webp
|
||||||
|
$originalData = (string) $img->encode('webp', $this->quality);
|
||||||
|
Storage::disk('public')->put($basePath . '/original.webp', $originalData);
|
||||||
|
|
||||||
|
// Generate sizes
|
||||||
|
foreach ($this->sizes as $name => $size) {
|
||||||
|
$resized = $img->resize($size, $size, function ($constraint) {
|
||||||
|
$constraint->upsize();
|
||||||
|
})->encode('webp', $this->quality);
|
||||||
|
Storage::disk('public')->put("{$basePath}/{$size}.webp", (string)$resized);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = sha1($originalData);
|
||||||
|
$mime = 'image/webp';
|
||||||
|
|
||||||
|
// Persist metadata to user_profiles if exists, otherwise users table fallbacks
|
||||||
|
if (SchemaHasTable('user_profiles')) {
|
||||||
|
DB::table('user_profiles')->where('user_id', $userId)->update([
|
||||||
|
'avatar_hash' => $hash,
|
||||||
|
'avatar_updated_at' => Carbon::now(),
|
||||||
|
'avatar_mime' => $mime,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
DB::table('users')->where('id', $userId)->update([
|
||||||
|
'avatar_hash' => $hash,
|
||||||
|
'avatar_updated_at' => Carbon::now(),
|
||||||
|
'avatar_mime' => $mime,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a legacy file path for a user (path-to-file).
|
||||||
|
* Returns sha1 or null when missing.
|
||||||
|
*
|
||||||
|
* @param int $userId
|
||||||
|
* @param string $path Absolute filesystem path
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function storeFromLegacyFile(int $userId, string $path): ?string
|
||||||
|
{
|
||||||
|
if (!file_exists($path) || !is_readable($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$img = Image::make($path);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$max = max($img->width(), $img->height());
|
||||||
|
$img->fit($max, $max);
|
||||||
|
|
||||||
|
$basePath = "avatars/{$userId}";
|
||||||
|
Storage::disk('public')->makeDirectory($basePath);
|
||||||
|
|
||||||
|
$originalData = (string) $img->encode('webp', $this->quality);
|
||||||
|
Storage::disk('public')->put($basePath . '/original.webp', $originalData);
|
||||||
|
|
||||||
|
foreach ($this->sizes as $name => $size) {
|
||||||
|
$resized = $img->resize($size, $size, function ($constraint) {
|
||||||
|
$constraint->upsize();
|
||||||
|
})->encode('webp', $this->quality);
|
||||||
|
Storage::disk('public')->put("{$basePath}/{$size}.webp", (string)$resized);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = sha1($originalData);
|
||||||
|
$mime = 'image/webp';
|
||||||
|
|
||||||
|
if (SchemaHasTable('user_profiles')) {
|
||||||
|
DB::table('user_profiles')->where('user_id', $userId)->update([
|
||||||
|
'avatar_hash' => $hash,
|
||||||
|
'avatar_updated_at' => Carbon::now(),
|
||||||
|
'avatar_mime' => $mime,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
DB::table('users')->where('id', $userId)->update([
|
||||||
|
'avatar_hash' => $hash,
|
||||||
|
'avatar_updated_at' => Carbon::now(),
|
||||||
|
'avatar_mime' => $mime,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: check for table existence without importing Schema facade repeatedly
|
||||||
|
*/
|
||||||
|
function SchemaHasTable(string $name): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return \Illuminate\Support\Facades\Schema::hasTable($name);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
139
app/Services/Recommendations/FeedOfflineEvaluationService.php
Normal file
139
app/Services/Recommendations/FeedOfflineEvaluationService.php
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Recommendations;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class FeedOfflineEvaluationService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function evaluateAlgo(string $algoVersion, string $from, string $to): array
|
||||||
|
{
|
||||||
|
$row = DB::table('feed_daily_metrics')
|
||||||
|
->selectRaw('SUM(impressions) as impressions')
|
||||||
|
->selectRaw('SUM(clicks) as clicks')
|
||||||
|
->selectRaw('SUM(saves) as saves')
|
||||||
|
->selectRaw('SUM(dwell_0_5) as dwell_0_5')
|
||||||
|
->selectRaw('SUM(dwell_5_30) as dwell_5_30')
|
||||||
|
->selectRaw('SUM(dwell_30_120) as dwell_30_120')
|
||||||
|
->selectRaw('SUM(dwell_120_plus) as dwell_120_plus')
|
||||||
|
->where('algo_version', $algoVersion)
|
||||||
|
->whereBetween('metric_date', [$from, $to])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$impressions = (int) ($row->impressions ?? 0);
|
||||||
|
$clicks = (int) ($row->clicks ?? 0);
|
||||||
|
$saves = (int) ($row->saves ?? 0);
|
||||||
|
|
||||||
|
$dwell05 = (int) ($row->dwell_0_5 ?? 0);
|
||||||
|
$dwell530 = (int) ($row->dwell_5_30 ?? 0);
|
||||||
|
$dwell30120 = (int) ($row->dwell_30_120 ?? 0);
|
||||||
|
$dwell120Plus = (int) ($row->dwell_120_plus ?? 0);
|
||||||
|
|
||||||
|
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
|
||||||
|
$saveRate = $clicks > 0 ? $saves / $clicks : 0.0;
|
||||||
|
$longDwellShare = $clicks > 0 ? ($dwell30120 + $dwell120Plus) / $clicks : 0.0;
|
||||||
|
$bounceRate = $clicks > 0 ? $dwell05 / $clicks : 0.0;
|
||||||
|
|
||||||
|
$objectiveWeights = (array) config('discovery.evaluation.objective_weights', []);
|
||||||
|
$wCtr = (float) ($objectiveWeights['ctr'] ?? 0.45);
|
||||||
|
$wSave = (float) ($objectiveWeights['save_rate'] ?? 0.35);
|
||||||
|
$wLong = (float) ($objectiveWeights['long_dwell_share'] ?? 0.25);
|
||||||
|
$wBouncePenalty = (float) ($objectiveWeights['bounce_rate_penalty'] ?? 0.15);
|
||||||
|
|
||||||
|
$saveRateInformational = (bool) config('discovery.evaluation.save_rate_informational', true);
|
||||||
|
if ($saveRateInformational) {
|
||||||
|
$wSave = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizationSum = $wCtr + $wSave + $wLong + $wBouncePenalty;
|
||||||
|
if ($normalizationSum > 0.0) {
|
||||||
|
$wCtr /= $normalizationSum;
|
||||||
|
$wSave /= $normalizationSum;
|
||||||
|
$wLong /= $normalizationSum;
|
||||||
|
$wBouncePenalty /= $normalizationSum;
|
||||||
|
}
|
||||||
|
|
||||||
|
$objectiveScore = ($wCtr * $ctr)
|
||||||
|
+ ($wSave * $saveRate)
|
||||||
|
+ ($wLong * $longDwellShare)
|
||||||
|
- ($wBouncePenalty * $bounceRate);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'algo_version' => $algoVersion,
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'impressions' => $impressions,
|
||||||
|
'clicks' => $clicks,
|
||||||
|
'saves' => $saves,
|
||||||
|
'ctr' => round($ctr, 6),
|
||||||
|
'save_rate' => round($saveRate, 6),
|
||||||
|
'long_dwell_share' => round($longDwellShare, 6),
|
||||||
|
'bounce_rate' => round($bounceRate, 6),
|
||||||
|
'dwell_buckets' => [
|
||||||
|
'0_5' => $dwell05,
|
||||||
|
'5_30' => $dwell530,
|
||||||
|
'30_120' => $dwell30120,
|
||||||
|
'120_plus' => $dwell120Plus,
|
||||||
|
],
|
||||||
|
'objective_score' => round($objectiveScore, 6),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function evaluateAll(string $from, string $to): array
|
||||||
|
{
|
||||||
|
$algoVersions = DB::table('feed_daily_metrics')
|
||||||
|
->select('algo_version')
|
||||||
|
->whereBetween('metric_date', [$from, $to])
|
||||||
|
->distinct()
|
||||||
|
->orderBy('algo_version')
|
||||||
|
->pluck('algo_version')
|
||||||
|
->map(static fn (mixed $v): string => (string) $v)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($algoVersions as $algoVersion) {
|
||||||
|
$out[] = $this->evaluateAlgo($algoVersion, $from, $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($out, static fn (array $a, array $b): int => $b['objective_score'] <=> $a['objective_score']);
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function compareBaselineCandidate(string $baselineAlgoVersion, string $candidateAlgoVersion, string $from, string $to): array
|
||||||
|
{
|
||||||
|
$baseline = $this->evaluateAlgo($baselineAlgoVersion, $from, $to);
|
||||||
|
$candidate = $this->evaluateAlgo($candidateAlgoVersion, $from, $to);
|
||||||
|
|
||||||
|
$deltaObjective = (float) $candidate['objective_score'] - (float) $baseline['objective_score'];
|
||||||
|
$objectiveLiftPct = (float) $baseline['objective_score'] !== 0.0
|
||||||
|
? ($deltaObjective / (float) $baseline['objective_score']) * 100.0
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'baseline' => $baseline,
|
||||||
|
'candidate' => $candidate,
|
||||||
|
'delta' => [
|
||||||
|
'objective_score' => round($deltaObjective, 6),
|
||||||
|
'objective_lift_pct' => $objectiveLiftPct !== null ? round($objectiveLiftPct, 4) : null,
|
||||||
|
'ctr' => round((float) $candidate['ctr'] - (float) $baseline['ctr'], 6),
|
||||||
|
'save_rate' => round((float) $candidate['save_rate'] - (float) $baseline['save_rate'], 6),
|
||||||
|
'long_dwell_share' => round((float) $candidate['long_dwell_share'] - (float) $baseline['long_dwell_share'], 6),
|
||||||
|
'bounce_rate' => round((float) $candidate['bounce_rate'] - (float) $baseline['bounce_rate'], 6),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
567
app/Services/Recommendations/PersonalizedFeedService.php
Normal file
567
app/Services/Recommendations/PersonalizedFeedService.php
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Recommendations;
|
||||||
|
|
||||||
|
use App\Jobs\RegenerateUserRecommendationCacheJob;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\UserInterestProfile;
|
||||||
|
use App\Models\UserRecommendationCache;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class PersonalizedFeedService
|
||||||
|
{
|
||||||
|
public function getFeed(int $userId, int $limit = 24, ?string $cursor = null, ?string $algoVersion = null): array
|
||||||
|
{
|
||||||
|
$safeLimit = max(1, min(50, $limit));
|
||||||
|
$resolvedAlgoVersion = $this->resolveAlgoVersion($algoVersion, $userId);
|
||||||
|
$weightSet = $this->resolveRankingWeights($resolvedAlgoVersion);
|
||||||
|
$offset = $this->decodeCursorToOffset($cursor);
|
||||||
|
|
||||||
|
$cache = UserRecommendationCache::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('algo_version', $resolvedAlgoVersion)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$cacheItems = $this->extractCacheItems($cache);
|
||||||
|
$isFresh = $cache !== null && $cache->expires_at !== null && $cache->expires_at->isFuture();
|
||||||
|
|
||||||
|
$cacheStatus = 'hit';
|
||||||
|
if ($cache === null) {
|
||||||
|
$cacheStatus = 'miss';
|
||||||
|
} elseif (! $isFresh) {
|
||||||
|
$cacheStatus = 'stale';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cache === null || ! $isFresh) {
|
||||||
|
RegenerateUserRecommendationCacheJob::dispatch($userId, $resolvedAlgoVersion)
|
||||||
|
->onQueue((string) config('discovery.queue', 'default'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $cacheItems;
|
||||||
|
if ($items === []) {
|
||||||
|
$items = $this->buildColdStartRecommendations($resolvedAlgoVersion, 240, 'fallback');
|
||||||
|
$cacheStatus = $cacheStatus . '-fallback';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->buildFeedPageResponse(
|
||||||
|
items: $items,
|
||||||
|
offset: $offset,
|
||||||
|
limit: $safeLimit,
|
||||||
|
algoVersion: $resolvedAlgoVersion,
|
||||||
|
weightVersion: (string) $weightSet['version'],
|
||||||
|
cacheStatus: $cacheStatus,
|
||||||
|
generatedAt: $cache?->generated_at?->toIso8601String()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function regenerateCacheForUser(int $userId, ?string $algoVersion = null): void
|
||||||
|
{
|
||||||
|
$resolvedAlgoVersion = $this->resolveAlgoVersion($algoVersion, $userId);
|
||||||
|
$cacheVersion = (string) config('discovery.cache_version', 'cache-v1');
|
||||||
|
$ttlMinutes = max(1, (int) config('discovery.cache_ttl_minutes', 60));
|
||||||
|
|
||||||
|
$items = $this->buildRecommendations($userId, $resolvedAlgoVersion, 240);
|
||||||
|
$generatedAt = now();
|
||||||
|
$expiresAt = now()->addMinutes($ttlMinutes);
|
||||||
|
|
||||||
|
UserRecommendationCache::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'user_id' => $userId,
|
||||||
|
'algo_version' => $resolvedAlgoVersion,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'cache_version' => $cacheVersion,
|
||||||
|
'recommendations_json' => [
|
||||||
|
'items' => $items,
|
||||||
|
'algo_version' => $resolvedAlgoVersion,
|
||||||
|
'weight_version' => (string) $this->resolveRankingWeights($resolvedAlgoVersion)['version'],
|
||||||
|
'generated_at' => $generatedAt->toIso8601String(),
|
||||||
|
],
|
||||||
|
'generated_at' => $generatedAt,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{artwork_id:int,score:float,source:string}>
|
||||||
|
*/
|
||||||
|
public function buildRecommendations(int $userId, string $algoVersion, int $maxItems = 240): array
|
||||||
|
{
|
||||||
|
$profileVersion = (string) config('discovery.profile_version', 'profile-v1');
|
||||||
|
|
||||||
|
$profile = UserInterestProfile::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('profile_version', $profileVersion)
|
||||||
|
->where('algo_version', $algoVersion)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$normalized = $profile !== null ? (array) ($profile->normalized_scores_json ?? []) : [];
|
||||||
|
$personalized = $this->buildProfileBasedRecommendations($normalized, $maxItems, $algoVersion);
|
||||||
|
|
||||||
|
if ($personalized === []) {
|
||||||
|
return $this->buildColdStartRecommendations($algoVersion, $maxItems, 'cold_start');
|
||||||
|
}
|
||||||
|
|
||||||
|
$fallback = $this->buildColdStartRecommendations($algoVersion, $maxItems, 'fallback');
|
||||||
|
|
||||||
|
$combined = [];
|
||||||
|
foreach (array_merge($personalized, $fallback) as $item) {
|
||||||
|
$artworkId = (int) ($item['artwork_id'] ?? 0);
|
||||||
|
if ($artworkId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($combined[$artworkId])) {
|
||||||
|
$combined[$artworkId] = [
|
||||||
|
'artwork_id' => $artworkId,
|
||||||
|
'score' => (float) ($item['score'] ?? 0.0),
|
||||||
|
'source' => (string) ($item['source'] ?? 'mixed'),
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((float) $item['score'] > (float) $combined[$artworkId]['score']) {
|
||||||
|
$combined[$artworkId]['score'] = (float) $item['score'];
|
||||||
|
$combined[$artworkId]['source'] = (string) ($item['source'] ?? $combined[$artworkId]['source']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = array_values($combined);
|
||||||
|
usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);
|
||||||
|
|
||||||
|
return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $normalizedScores
|
||||||
|
* @return array<int, array{artwork_id:int,score:float,source:string}>
|
||||||
|
*/
|
||||||
|
private function buildProfileBasedRecommendations(array $normalizedScores, int $maxItems, string $algoVersion): array
|
||||||
|
{
|
||||||
|
$weightSet = $this->resolveRankingWeights($algoVersion);
|
||||||
|
$w1 = (float) $weightSet['w1'];
|
||||||
|
$w2 = (float) $weightSet['w2'];
|
||||||
|
$w3 = (float) $weightSet['w3'];
|
||||||
|
$w4 = (float) $weightSet['w4'];
|
||||||
|
|
||||||
|
$categoryAffinities = [];
|
||||||
|
foreach ($normalizedScores as $key => $score) {
|
||||||
|
if (! is_numeric($score)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! str_starts_with((string) $key, 'category:')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$categoryId = (int) str_replace('category:', '', (string) $key);
|
||||||
|
if ($categoryId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$categoryAffinities[$categoryId] = (float) $score;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($categoryAffinities === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = DB::table('artworks')
|
||||||
|
->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id')
|
||||||
|
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||||
|
->whereIn('artwork_category.category_id', array_keys($categoryAffinities))
|
||||||
|
->whereNull('artworks.deleted_at')
|
||||||
|
->where('artworks.is_public', true)
|
||||||
|
->where('artworks.is_approved', true)
|
||||||
|
->whereNotNull('artworks.published_at')
|
||||||
|
->where('artworks.published_at', '<=', now())
|
||||||
|
->orderByDesc('artworks.published_at')
|
||||||
|
->limit(max(200, $maxItems * 8))
|
||||||
|
->get([
|
||||||
|
'artworks.id',
|
||||||
|
'artworks.published_at',
|
||||||
|
'artwork_category.category_id',
|
||||||
|
DB::raw('COALESCE(artwork_stats.views, 0) as views'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$scored = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$artworkId = (int) $row->id;
|
||||||
|
$categoryId = (int) $row->category_id;
|
||||||
|
$affinity = (float) ($categoryAffinities[$categoryId] ?? 0.0);
|
||||||
|
if ($affinity <= 0.0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$publishedAt = CarbonImmutable::parse((string) $row->published_at);
|
||||||
|
$ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400);
|
||||||
|
$recency = exp(-$ageDays / 30.0);
|
||||||
|
$popularity = log(1 + max(0, (int) $row->views)) / 10.0;
|
||||||
|
$novelty = max(0.0, 1.0 - min(1.0, $popularity));
|
||||||
|
|
||||||
|
// Phase 8B blend with versioned weights (manual tuning, no auto-tuning yet).
|
||||||
|
$score = ($w1 * $affinity) + ($w2 * $recency) + ($w3 * $popularity) + ($w4 * $novelty);
|
||||||
|
|
||||||
|
if (! isset($scored[$artworkId]) || $score > $scored[$artworkId]['score']) {
|
||||||
|
$scored[$artworkId] = [
|
||||||
|
'artwork_id' => $artworkId,
|
||||||
|
'score' => $score,
|
||||||
|
'source' => 'personalized',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = array_values($scored);
|
||||||
|
usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);
|
||||||
|
|
||||||
|
return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{artwork_id:int,score:float,source:string}>
|
||||||
|
*/
|
||||||
|
private function buildColdStartRecommendations(string $algoVersion, int $maxItems, string $sourceLabel = 'cold_start'): array
|
||||||
|
{
|
||||||
|
$popularIds = DB::table('artworks')
|
||||||
|
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
||||||
|
->whereNull('artworks.deleted_at')
|
||||||
|
->where('artworks.is_public', true)
|
||||||
|
->where('artworks.is_approved', true)
|
||||||
|
->whereNotNull('artworks.published_at')
|
||||||
|
->where('artworks.published_at', '<=', now())
|
||||||
|
->orderByDesc('artwork_stats.views')
|
||||||
|
->orderByDesc('artwork_stats.downloads')
|
||||||
|
->orderByDesc('artworks.published_at')
|
||||||
|
->limit(max(40, $maxItems))
|
||||||
|
->pluck('artworks.id')
|
||||||
|
->map(static fn (mixed $id): int => (int) $id)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$seedIds = array_slice($popularIds, 0, 12);
|
||||||
|
|
||||||
|
$similarIds = [];
|
||||||
|
if ($seedIds !== []) {
|
||||||
|
$similarIds = DB::table('artwork_similarities')
|
||||||
|
->where('algo_version', $algoVersion)
|
||||||
|
->whereIn('artwork_id', $seedIds)
|
||||||
|
->orderBy('rank')
|
||||||
|
->orderByDesc('score')
|
||||||
|
->limit(max(80, $maxItems * 2))
|
||||||
|
->pluck('similar_artwork_id')
|
||||||
|
->map(static fn (mixed $id): int => (int) $id)
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = [];
|
||||||
|
foreach ($popularIds as $index => $artworkId) {
|
||||||
|
$candidates[] = [
|
||||||
|
'artwork_id' => $artworkId,
|
||||||
|
'score' => max(0.0, 1.0 - ($index * 0.003)),
|
||||||
|
'source' => $sourceLabel,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($similarIds as $index => $artworkId) {
|
||||||
|
$candidates[] = [
|
||||||
|
'artwork_id' => $artworkId,
|
||||||
|
'score' => max(0.0, 0.75 - ($index * 0.002)),
|
||||||
|
'source' => $sourceLabel,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($candidates, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);
|
||||||
|
|
||||||
|
return $this->applyDiversityGuard($candidates, $algoVersion, $maxItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{artwork_id:int,score:float,source:string}> $candidates
|
||||||
|
* @return array<int, array{artwork_id:int,score:float,source:string}>
|
||||||
|
*/
|
||||||
|
private function applyDiversityGuard(array $candidates, string $algoVersion, int $maxItems): array
|
||||||
|
{
|
||||||
|
if ($candidates === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$uniqueCandidates = [];
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
$artworkId = (int) ($candidate['artwork_id'] ?? 0);
|
||||||
|
if ($artworkId <= 0 || isset($uniqueCandidates[$artworkId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uniqueCandidates[$artworkId] = [
|
||||||
|
'artwork_id' => $artworkId,
|
||||||
|
'score' => (float) ($candidate['score'] ?? 0.0),
|
||||||
|
'source' => (string) ($candidate['source'] ?? 'mixed'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$flattened = array_values($uniqueCandidates);
|
||||||
|
$candidateIds = array_map(static fn (array $item): int => (int) $item['artwork_id'], $flattened);
|
||||||
|
|
||||||
|
$nearDuplicatePairs = DB::table('artwork_similarities')
|
||||||
|
->where('algo_version', $algoVersion)
|
||||||
|
->where('score', '>=', 0.97)
|
||||||
|
->whereIn('artwork_id', $candidateIds)
|
||||||
|
->whereIn('similar_artwork_id', $candidateIds)
|
||||||
|
->get(['artwork_id', 'similar_artwork_id']);
|
||||||
|
|
||||||
|
$adjacency = [];
|
||||||
|
foreach ($nearDuplicatePairs as $pair) {
|
||||||
|
$left = (int) $pair->artwork_id;
|
||||||
|
$right = (int) $pair->similar_artwork_id;
|
||||||
|
|
||||||
|
if ($left === $right) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$adjacency[$left][$right] = true;
|
||||||
|
$adjacency[$right][$left] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selected = [];
|
||||||
|
$selectedSet = [];
|
||||||
|
|
||||||
|
foreach ($flattened as $candidate) {
|
||||||
|
$id = (int) $candidate['artwork_id'];
|
||||||
|
|
||||||
|
$isNearDuplicate = false;
|
||||||
|
foreach ($selectedSet as $selectedId => $value) {
|
||||||
|
if (($adjacency[$id][$selectedId] ?? false) || ($adjacency[$selectedId][$id] ?? false)) {
|
||||||
|
$isNearDuplicate = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isNearDuplicate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selected[] = [
|
||||||
|
'artwork_id' => $id,
|
||||||
|
'score' => round((float) $candidate['score'], 6),
|
||||||
|
'source' => (string) $candidate['source'],
|
||||||
|
];
|
||||||
|
$selectedSet[$id] = true;
|
||||||
|
|
||||||
|
if (count($selected) >= $maxItems) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{artwork_id:int,score:float,source:string}> $items
|
||||||
|
*/
|
||||||
|
private function buildFeedPageResponse(
|
||||||
|
array $items,
|
||||||
|
int $offset,
|
||||||
|
int $limit,
|
||||||
|
string $algoVersion,
|
||||||
|
string $weightVersion,
|
||||||
|
string $cacheStatus,
|
||||||
|
?string $generatedAt
|
||||||
|
): array {
|
||||||
|
$safeOffset = max(0, $offset);
|
||||||
|
$pageItems = array_slice($items, $safeOffset, $limit);
|
||||||
|
|
||||||
|
$ids = array_values(array_unique(array_map(
|
||||||
|
static fn (array $item): int => (int) ($item['artwork_id'] ?? 0),
|
||||||
|
$pageItems
|
||||||
|
)));
|
||||||
|
|
||||||
|
/** @var Collection<int, Artwork> $artworks */
|
||||||
|
$artworks = Artwork::query()
|
||||||
|
->with(['user:id,name'])
|
||||||
|
->whereIn('id', $ids)
|
||||||
|
->public()
|
||||||
|
->published()
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
|
||||||
|
$responseItems = [];
|
||||||
|
foreach ($pageItems as $item) {
|
||||||
|
$artworkId = (int) ($item['artwork_id'] ?? 0);
|
||||||
|
$artwork = $artworks->get($artworkId);
|
||||||
|
if ($artwork === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$responseItems[] = [
|
||||||
|
'id' => $artwork->id,
|
||||||
|
'slug' => $artwork->slug,
|
||||||
|
'title' => $artwork->title,
|
||||||
|
'thumbnail_url' => $artwork->thumb_url,
|
||||||
|
'author' => $artwork->user?->name,
|
||||||
|
'score' => (float) ($item['score'] ?? 0.0),
|
||||||
|
'source' => (string) ($item['source'] ?? 'mixed'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextOffset = $safeOffset + $limit;
|
||||||
|
$hasNext = $nextOffset < count($items);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'data' => $responseItems,
|
||||||
|
'meta' => [
|
||||||
|
'algo_version' => $algoVersion,
|
||||||
|
'weight_version' => $weightVersion,
|
||||||
|
'cursor' => $this->encodeOffsetToCursor($safeOffset),
|
||||||
|
'next_cursor' => $hasNext ? $this->encodeOffsetToCursor($nextOffset) : null,
|
||||||
|
'limit' => $limit,
|
||||||
|
'cache_status' => $cacheStatus,
|
||||||
|
'generated_at' => $generatedAt,
|
||||||
|
'total_candidates' => count($items),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveAlgoVersion(?string $algoVersion = null, ?int $userId = null): string
|
||||||
|
{
|
||||||
|
if ($algoVersion !== null && $algoVersion !== '') {
|
||||||
|
return $algoVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
$forcedAlgoVersion = trim((string) config('discovery.rollout.force_algo_version', ''));
|
||||||
|
if ($forcedAlgoVersion !== '') {
|
||||||
|
return $forcedAlgoVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
$defaultAlgoVersion = (string) config('discovery.algo_version', 'clip-cosine-v1');
|
||||||
|
$rolloutEnabled = (bool) config('discovery.rollout.enabled', false);
|
||||||
|
if (! $rolloutEnabled || $userId === null || $userId <= 0) {
|
||||||
|
return $defaultAlgoVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baselineAlgoVersion = (string) config('discovery.rollout.baseline_algo_version', $defaultAlgoVersion);
|
||||||
|
$candidateAlgoVersion = (string) config('discovery.rollout.candidate_algo_version', $defaultAlgoVersion);
|
||||||
|
if ($candidateAlgoVersion === '' || $candidateAlgoVersion === $baselineAlgoVersion) {
|
||||||
|
return $baselineAlgoVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeGate = (string) config('discovery.rollout.active_gate', 'g10');
|
||||||
|
$gates = (array) config('discovery.rollout.gates', []);
|
||||||
|
$gate = (array) ($gates[$activeGate] ?? []);
|
||||||
|
$rolloutPercentage = (int) ($gate['percentage'] ?? 0);
|
||||||
|
$rolloutPercentage = max(0, min(100, $rolloutPercentage));
|
||||||
|
|
||||||
|
if ($rolloutPercentage <= 0) {
|
||||||
|
return $baselineAlgoVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rolloutPercentage >= 100) {
|
||||||
|
return $candidateAlgoVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bucket = abs((int) crc32((string) $userId)) % 100;
|
||||||
|
|
||||||
|
return $bucket < $rolloutPercentage
|
||||||
|
? $candidateAlgoVersion
|
||||||
|
: $baselineAlgoVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{version:string,w1:float,w2:float,w3:float,w4:float}
|
||||||
|
*/
|
||||||
|
public function resolveRankingWeights(string $algoVersion): array
|
||||||
|
{
|
||||||
|
$defaults = (array) config('discovery.ranking.default_weights', []);
|
||||||
|
$byAlgo = (array) config('discovery.ranking.algo_weight_sets', []);
|
||||||
|
$override = (array) ($byAlgo[$algoVersion] ?? []);
|
||||||
|
|
||||||
|
$resolved = array_merge($defaults, $override);
|
||||||
|
|
||||||
|
$weights = [
|
||||||
|
'version' => (string) ($resolved['version'] ?? 'rank-w-v1'),
|
||||||
|
'w1' => max(0.0, (float) ($resolved['w1'] ?? 0.65)),
|
||||||
|
'w2' => max(0.0, (float) ($resolved['w2'] ?? 0.20)),
|
||||||
|
'w3' => max(0.0, (float) ($resolved['w3'] ?? 0.10)),
|
||||||
|
'w4' => max(0.0, (float) ($resolved['w4'] ?? 0.05)),
|
||||||
|
];
|
||||||
|
|
||||||
|
$sum = $weights['w1'] + $weights['w2'] + $weights['w3'] + $weights['w4'];
|
||||||
|
if ($sum > 0.0) {
|
||||||
|
$weights['w1'] /= $sum;
|
||||||
|
$weights['w2'] /= $sum;
|
||||||
|
$weights['w3'] /= $sum;
|
||||||
|
$weights['w4'] /= $sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $weights;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeCursorToOffset(?string $cursor): int
|
||||||
|
{
|
||||||
|
if ($cursor === null || $cursor === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = base64_decode(strtr($cursor, '-_', '+/'), true);
|
||||||
|
if ($decoded === false) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_decode($decoded, true);
|
||||||
|
if (! is_array($json)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(0, (int) Arr::get($json, 'offset', 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function encodeOffsetToCursor(int $offset): string
|
||||||
|
{
|
||||||
|
$payload = json_encode(['offset' => max(0, $offset)]);
|
||||||
|
if (! is_string($payload)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{artwork_id:int,score:float,source:string}>
|
||||||
|
*/
|
||||||
|
private function extractCacheItems(?UserRecommendationCache $cache): array
|
||||||
|
{
|
||||||
|
if ($cache === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = (array) ($cache->recommendations_json ?? []);
|
||||||
|
$items = $raw['items'] ?? null;
|
||||||
|
if (! is_array($items)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$typed = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if (! is_array($item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$artworkId = (int) ($item['artwork_id'] ?? 0);
|
||||||
|
if ($artworkId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$typed[] = [
|
||||||
|
'artwork_id' => $artworkId,
|
||||||
|
'score' => (float) ($item['score'] ?? 0.0),
|
||||||
|
'source' => (string) ($item['source'] ?? 'mixed'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $typed;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Services/Recommendations/SimilarArtworksService.php
Normal file
45
app/Services/Recommendations/SimilarArtworksService.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Recommendations;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class SimilarArtworksService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Artwork>
|
||||||
|
*/
|
||||||
|
public function forArtwork(int $artworkId, int $limit = 12, ?string $algoVersion = null): Collection
|
||||||
|
{
|
||||||
|
$effectiveAlgo = $algoVersion ?: (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1');
|
||||||
|
|
||||||
|
$ids = DB::table('artwork_similarities')
|
||||||
|
->where('artwork_id', $artworkId)
|
||||||
|
->where('algo_version', $effectiveAlgo)
|
||||||
|
->orderBy('rank')
|
||||||
|
->limit(max(1, min($limit, 50)))
|
||||||
|
->pluck('similar_artwork_id')
|
||||||
|
->map(static fn ($id) => (int) $id)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($ids === []) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$artworks = Artwork::query()
|
||||||
|
->whereIn('id', $ids)
|
||||||
|
->public()
|
||||||
|
->published()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$byId = $artworks->keyBy('id');
|
||||||
|
|
||||||
|
return collect($ids)
|
||||||
|
->map(static fn (int $id) => $byId->get($id))
|
||||||
|
->filter();
|
||||||
|
}
|
||||||
|
}
|
||||||
162
app/Services/Recommendations/UserInterestProfileService.php
Normal file
162
app/Services/Recommendations/UserInterestProfileService.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Recommendations;
|
||||||
|
|
||||||
|
use App\Models\UserInterestProfile;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class UserInterestProfileService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $eventMeta
|
||||||
|
*/
|
||||||
|
public function applyEvent(
|
||||||
|
int $userId,
|
||||||
|
string $eventType,
|
||||||
|
int $artworkId,
|
||||||
|
?int $categoryId,
|
||||||
|
CarbonInterface $occurredAt,
|
||||||
|
string $eventId,
|
||||||
|
string $algoVersion,
|
||||||
|
array $eventMeta = []
|
||||||
|
): void {
|
||||||
|
$profileVersion = (string) config('discovery.profile_version', 'profile-v1');
|
||||||
|
$halfLifeHours = (float) config('discovery.decay.half_life_hours', 72);
|
||||||
|
$weightMap = (array) config('discovery.weights', []);
|
||||||
|
$eventWeight = (float) ($weightMap[$eventType] ?? 1.0);
|
||||||
|
|
||||||
|
DB::transaction(function () use (
|
||||||
|
$userId,
|
||||||
|
$categoryId,
|
||||||
|
$artworkId,
|
||||||
|
$occurredAt,
|
||||||
|
$eventId,
|
||||||
|
$algoVersion,
|
||||||
|
$profileVersion,
|
||||||
|
$halfLifeHours,
|
||||||
|
$eventWeight,
|
||||||
|
$eventMeta
|
||||||
|
): void {
|
||||||
|
$profile = UserInterestProfile::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('profile_version', $profileVersion)
|
||||||
|
->where('algo_version', $algoVersion)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$rawScores = $profile !== null ? (array) ($profile->raw_scores_json ?? []) : [];
|
||||||
|
$lastEventAt = $profile?->last_event_at;
|
||||||
|
|
||||||
|
if ($lastEventAt !== null && $occurredAt->greaterThan($lastEventAt)) {
|
||||||
|
$hours = max(0.0, (float) $lastEventAt->diffInSeconds($occurredAt) / 3600);
|
||||||
|
$rawScores = $this->applyRecencyDecay($rawScores, $hours, $halfLifeHours);
|
||||||
|
}
|
||||||
|
|
||||||
|
$interestKey = $categoryId !== null
|
||||||
|
? sprintf('category:%d', $categoryId)
|
||||||
|
: sprintf('artwork:%d', $artworkId);
|
||||||
|
|
||||||
|
$rawScores[$interestKey] = (float) ($rawScores[$interestKey] ?? 0.0) + $eventWeight;
|
||||||
|
|
||||||
|
$rawScores = array_filter(
|
||||||
|
$rawScores,
|
||||||
|
static fn (mixed $value): bool => is_numeric($value) && (float) $value > 0.000001
|
||||||
|
);
|
||||||
|
|
||||||
|
$normalizedScores = $this->normalizeScores($rawScores);
|
||||||
|
$totalWeight = array_sum($rawScores);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'profile_version' => $profileVersion,
|
||||||
|
'algo_version' => $algoVersion,
|
||||||
|
'raw_scores_json' => $rawScores,
|
||||||
|
'normalized_scores_json' => $normalizedScores,
|
||||||
|
'total_weight' => $totalWeight,
|
||||||
|
'event_count' => $profile !== null ? ((int) $profile->event_count + 1) : 1,
|
||||||
|
'last_event_at' => $lastEventAt === null || $occurredAt->greaterThan($lastEventAt)
|
||||||
|
? $occurredAt
|
||||||
|
: $lastEventAt,
|
||||||
|
'half_life_hours' => $halfLifeHours,
|
||||||
|
'updated_from_event_id' => $eventId,
|
||||||
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($profile === null) {
|
||||||
|
$payload['created_at'] = now();
|
||||||
|
UserInterestProfile::query()->create($payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile->fill($payload);
|
||||||
|
$profile->save();
|
||||||
|
}, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $scores
|
||||||
|
* @return array<string, float>
|
||||||
|
*/
|
||||||
|
public function applyRecencyDecay(array $scores, float $hoursElapsed, float $halfLifeHours): array
|
||||||
|
{
|
||||||
|
if ($hoursElapsed <= 0 || $halfLifeHours <= 0) {
|
||||||
|
return $this->castToFloatScores($scores);
|
||||||
|
}
|
||||||
|
|
||||||
|
$decayFactor = exp(-log(2) * ($hoursElapsed / $halfLifeHours));
|
||||||
|
$output = [];
|
||||||
|
|
||||||
|
foreach ($scores as $key => $score) {
|
||||||
|
if (! is_numeric($score)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decayed = (float) $score * $decayFactor;
|
||||||
|
if ($decayed > 0.000001) {
|
||||||
|
$output[(string) $key] = $decayed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $scores
|
||||||
|
* @return array<string, float>
|
||||||
|
*/
|
||||||
|
public function normalizeScores(array $scores): array
|
||||||
|
{
|
||||||
|
$typedScores = $this->castToFloatScores($scores);
|
||||||
|
$sum = array_sum($typedScores);
|
||||||
|
|
||||||
|
if ($sum <= 0.0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
foreach ($typedScores as $key => $score) {
|
||||||
|
$normalized[$key] = $score / $sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $scores
|
||||||
|
* @return array<string, float>
|
||||||
|
*/
|
||||||
|
private function castToFloatScores(array $scores): array
|
||||||
|
{
|
||||||
|
$output = [];
|
||||||
|
foreach ($scores as $key => $score) {
|
||||||
|
if (is_numeric($score) && (float) $score > 0.0) {
|
||||||
|
$output[(string) $key] = (float) $score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Services/TagNormalizer.php
Normal file
39
app/Services/TagNormalizer.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
final class TagNormalizer
|
||||||
|
{
|
||||||
|
public function normalize(string $tag): string
|
||||||
|
{
|
||||||
|
$value = trim($tag);
|
||||||
|
if ($value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = mb_strtolower($value, 'UTF-8');
|
||||||
|
|
||||||
|
// Remove emoji / symbols and keep only letters, numbers, whitespace and hyphens.
|
||||||
|
// (Unicode safe: \p{L} letters, \p{N} numbers)
|
||||||
|
$value = (string) preg_replace('/[^\p{L}\p{N}\s\-]+/u', '', $value);
|
||||||
|
|
||||||
|
// Normalize whitespace.
|
||||||
|
$value = (string) preg_replace('/\s+/u', ' ', $value);
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
// Spaces -> hyphens and collapse repeats.
|
||||||
|
$value = str_replace(' ', '-', $value);
|
||||||
|
$value = (string) preg_replace('/\-+/u', '-', $value);
|
||||||
|
$value = trim($value, "-\t\n\r\0\x0B");
|
||||||
|
|
||||||
|
$maxLength = (int) config('tags.max_length', 32);
|
||||||
|
if ($maxLength > 0 && mb_strlen($value, 'UTF-8') > $maxLength) {
|
||||||
|
$value = mb_substr($value, 0, $maxLength, 'UTF-8');
|
||||||
|
$value = rtrim($value, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
329
app/Services/TagService.php
Normal file
329
app/Services/TagService.php
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\Tag;
|
||||||
|
use App\Services\TagNormalizer;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
final class TagService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TagNormalizer $normalizer,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createOrFindTag(string $rawTag): Tag
|
||||||
|
{
|
||||||
|
$normalized = $this->normalizer->normalize($rawTag);
|
||||||
|
$this->validateNormalizedTag($normalized);
|
||||||
|
|
||||||
|
// Keep tags normalized in both name and slug (spec: normalize all tags).
|
||||||
|
// Unique(slug) + Unique(name) prevents duplicates.
|
||||||
|
return Tag::query()->firstOrCreate(
|
||||||
|
['slug' => $normalized],
|
||||||
|
['name' => $normalized, 'usage_count' => 0, 'is_active' => true]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $tags
|
||||||
|
*/
|
||||||
|
public function attachUserTags(Artwork $artwork, array $tags): void
|
||||||
|
{
|
||||||
|
$normalized = $this->normalizeUserTags($tags);
|
||||||
|
if ($normalized === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($artwork, $normalized): void {
|
||||||
|
$tagIdsBySlug = [];
|
||||||
|
foreach ($normalized as $tag) {
|
||||||
|
$model = $this->createOrFindTag($tag);
|
||||||
|
$tagIdsBySlug[$model->slug] = $model->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagIds = array_values($tagIdsBySlug);
|
||||||
|
|
||||||
|
$existing = DB::table('artwork_tag')
|
||||||
|
->where('artwork_id', $artwork->id)
|
||||||
|
->whereIn('tag_id', $tagIds)
|
||||||
|
->pluck('source', 'tag_id')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$toAttach = [];
|
||||||
|
$toUpdate = [];
|
||||||
|
$newlyAttachedTagIds = [];
|
||||||
|
|
||||||
|
foreach ($tagIds as $tagId) {
|
||||||
|
$source = $existing[$tagId] ?? null;
|
||||||
|
|
||||||
|
if ($source === null) {
|
||||||
|
$toAttach[$tagId] = ['source' => 'user', 'confidence' => null, 'created_at' => now()];
|
||||||
|
$newlyAttachedTagIds[] = $tagId;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($source !== 'user') {
|
||||||
|
// User tags take precedence over AI/system.
|
||||||
|
$toUpdate[$tagId] = ['source' => 'user', 'confidence' => null];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($toAttach !== []) {
|
||||||
|
$artwork->tags()->syncWithoutDetaching($toAttach);
|
||||||
|
$this->incrementUsageCounts($newlyAttachedTagIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($toUpdate as $tagId => $payload) {
|
||||||
|
$artwork->tags()->updateExistingPivot($tagId, $payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{tag: string, confidence?: float|int|null}> $aiTags
|
||||||
|
*/
|
||||||
|
public function attachAiTags(Artwork $artwork, array $aiTags): void
|
||||||
|
{
|
||||||
|
if ($aiTags === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($artwork, $aiTags): void {
|
||||||
|
$payloads = [];
|
||||||
|
$newlyAttachedTagIds = [];
|
||||||
|
|
||||||
|
foreach ($aiTags as $row) {
|
||||||
|
$raw = (string) ($row['tag'] ?? '');
|
||||||
|
$confidence = $row['confidence'] ?? null;
|
||||||
|
|
||||||
|
$normalized = $this->normalizer->normalize($raw);
|
||||||
|
if ($normalized === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI tagging must be optional: invalid/banned tags are skipped (not fatal).
|
||||||
|
try {
|
||||||
|
$this->validateNormalizedTag($normalized);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tag = $this->createOrFindTag($normalized);
|
||||||
|
|
||||||
|
$existingSource = DB::table('artwork_tag')
|
||||||
|
->where('artwork_id', $artwork->id)
|
||||||
|
->where('tag_id', $tag->id)
|
||||||
|
->value('source');
|
||||||
|
|
||||||
|
if ($existingSource === 'user') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existingSource === null) {
|
||||||
|
$payloads[$tag->id] = [
|
||||||
|
'source' => 'ai',
|
||||||
|
'confidence' => is_numeric($confidence) ? (float) $confidence : null,
|
||||||
|
'created_at' => now(),
|
||||||
|
];
|
||||||
|
$newlyAttachedTagIds[] = $tag->id;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existingSource === 'ai') {
|
||||||
|
$artwork->tags()->updateExistingPivot($tag->id, [
|
||||||
|
'confidence' => is_numeric($confidence) ? (float) $confidence : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($payloads !== []) {
|
||||||
|
$artwork->tags()->syncWithoutDetaching($payloads);
|
||||||
|
$this->incrementUsageCounts($newlyAttachedTagIds);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function detachTags(Artwork $artwork, array $tagSlugsOrIds): void
|
||||||
|
{
|
||||||
|
if ($tagSlugsOrIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagIds = Tag::query()
|
||||||
|
->whereIn('id', array_filter($tagSlugsOrIds, 'is_numeric'))
|
||||||
|
->orWhereIn('slug', array_filter($tagSlugsOrIds, fn ($v) => is_string($v) && $v !== ''))
|
||||||
|
->pluck('id')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($tagIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($artwork, $tagIds): void {
|
||||||
|
$existing = DB::table('artwork_tag')
|
||||||
|
->where('artwork_id', $artwork->id)
|
||||||
|
->whereIn('tag_id', $tagIds)
|
||||||
|
->pluck('tag_id')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($existing === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$artwork->tags()->detach($existing);
|
||||||
|
$this->decrementUsageCounts($existing);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync user tags (PUT semantics): replaces the set of user-origin tags.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $tags
|
||||||
|
*/
|
||||||
|
public function syncTags(Artwork $artwork, array $tags): void
|
||||||
|
{
|
||||||
|
$normalized = $this->normalizeUserTags($tags);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($artwork, $normalized): void {
|
||||||
|
$desiredTagIds = [];
|
||||||
|
foreach ($normalized as $tag) {
|
||||||
|
$model = $this->createOrFindTag($tag);
|
||||||
|
$desiredTagIds[] = $model->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$desiredTagIds = array_values(array_unique($desiredTagIds));
|
||||||
|
|
||||||
|
$currentUserTagIds = DB::table('artwork_tag')
|
||||||
|
->where('artwork_id', $artwork->id)
|
||||||
|
->where('source', 'user')
|
||||||
|
->pluck('tag_id')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$toDetach = array_values(array_diff($currentUserTagIds, $desiredTagIds));
|
||||||
|
$toAttach = array_values(array_diff($desiredTagIds, $currentUserTagIds));
|
||||||
|
|
||||||
|
if ($toDetach !== []) {
|
||||||
|
$artwork->tags()->detach($toDetach);
|
||||||
|
$this->decrementUsageCounts($toDetach);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($toAttach !== []) {
|
||||||
|
$payload = [];
|
||||||
|
foreach ($toAttach as $tagId) {
|
||||||
|
$payload[$tagId] = ['source' => 'user', 'confidence' => null, 'created_at' => now()];
|
||||||
|
}
|
||||||
|
$artwork->tags()->syncWithoutDetaching($payload);
|
||||||
|
$this->incrementUsageCounts($toAttach);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure desired tags are marked as user (user precedence).
|
||||||
|
if ($desiredTagIds !== []) {
|
||||||
|
$existingNonUser = DB::table('artwork_tag')
|
||||||
|
->where('artwork_id', $artwork->id)
|
||||||
|
->whereIn('tag_id', $desiredTagIds)
|
||||||
|
->where('source', '!=', 'user')
|
||||||
|
->pluck('tag_id')
|
||||||
|
->all();
|
||||||
|
|
||||||
|
foreach ($existingNonUser as $tagId) {
|
||||||
|
$artwork->tags()->updateExistingPivot($tagId, ['source' => 'user', 'confidence' => null]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateUsageCount(Tag $tag): void
|
||||||
|
{
|
||||||
|
$count = (int) DB::table('artwork_tag')->where('tag_id', $tag->id)->count();
|
||||||
|
$tag->forceFill(['usage_count' => $count])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $tags
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function normalizeUserTags(array $tags): array
|
||||||
|
{
|
||||||
|
$max = (int) config('tags.max_user_tags', 15);
|
||||||
|
if (count($tags) > $max) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'tags' => ["Too many tags (max {$max})."],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
foreach ($tags as $tag) {
|
||||||
|
$value = $this->normalizer->normalize((string) $tag);
|
||||||
|
if ($value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->validateNormalizedTag($value);
|
||||||
|
$normalized[] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = array_values(array_unique($normalized));
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateNormalizedTag(string $normalized): void
|
||||||
|
{
|
||||||
|
if ($normalized === '') {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'tags' => ['Invalid tag.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$banned = array_map('strval', (array) config('tags.banned', []));
|
||||||
|
if ($banned !== [] && in_array($normalized, $banned, true)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'tags' => ['Tag is not allowed.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$patterns = (array) config('tags.banned_regex', []);
|
||||||
|
foreach ($patterns as $pattern) {
|
||||||
|
$pattern = (string) $pattern;
|
||||||
|
if ($pattern === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (@preg_match($pattern, $normalized) === 1) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'tags' => ['Tag is not allowed.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $tagIds
|
||||||
|
*/
|
||||||
|
private function incrementUsageCounts(array $tagIds): void
|
||||||
|
{
|
||||||
|
if ($tagIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Tag::query()->whereIn('id', $tagIds)->increment('usage_count');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, int> $tagIds
|
||||||
|
*/
|
||||||
|
private function decrementUsageCounts(array $tagIds): void
|
||||||
|
{
|
||||||
|
if ($tagIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never allow negative counts.
|
||||||
|
DB::table('tags')
|
||||||
|
->whereIn('id', $tagIds)
|
||||||
|
->update(['usage_count' => DB::raw('CASE WHEN usage_count > 0 THEN usage_count - 1 ELSE 0 END')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
|
|
||||||
class ThumbnailService
|
class ThumbnailService
|
||||||
{
|
{
|
||||||
protected const CDN_HOST = 'http://files.skinbase.org';
|
// Use the thumbnails CDN host (HTTPS)
|
||||||
|
protected const CDN_HOST = 'https://files.skinbase.org';
|
||||||
|
|
||||||
protected const VALID_SIZES = ['sm','md','lg','xl'];
|
protected const VALID_SIZES = ['sm','md','lg','xl'];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Upload\Contracts;
|
||||||
|
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
|
||||||
|
interface UploadDraftServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a new draft and return identifying info.
|
||||||
|
*
|
||||||
|
* @param array $attributes
|
||||||
|
* @return array ['id' => string, 'path' => string, 'meta' => array]
|
||||||
|
*/
|
||||||
|
public function createDraft(array $attributes = []): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the main uploaded file for the draft.
|
||||||
|
*
|
||||||
|
* @param string $draftId
|
||||||
|
* @param UploadedFile $file
|
||||||
|
* @return array Metadata about stored file (path, size, mime, hash)
|
||||||
|
*/
|
||||||
|
public function storeMainFile(string $draftId, UploadedFile $file): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a screenshot/preview image for the draft.
|
||||||
|
*
|
||||||
|
* @param string $draftId
|
||||||
|
* @param UploadedFile $file
|
||||||
|
* @return array Metadata about stored screenshot
|
||||||
|
*/
|
||||||
|
public function storeScreenshot(string $draftId, UploadedFile $file): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate a content hash for a local file path or storage path.
|
||||||
|
*
|
||||||
|
* @param string $filePath
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function calculateHash(string $filePath): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an expiration timestamp for the draft.
|
||||||
|
*
|
||||||
|
* @param string $draftId
|
||||||
|
* @param \Carbon\Carbon|null $expiresAt
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function setExpiration(string $draftId, ?\Carbon\Carbon $expiresAt = null): bool;
|
||||||
|
}
|
||||||
79
app/Services/Upload/PreviewService.php
Normal file
79
app/Services/Upload/PreviewService.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Upload;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Intervention\Image\ImageManager;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class PreviewService
|
||||||
|
{
|
||||||
|
private ?ImageManager $manager = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->manager = extension_loaded('gd') ? ImageManager::gd() : ImageManager::imagick();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->manager = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateFromImage(string $uploadId, string $sourcePath): array
|
||||||
|
{
|
||||||
|
if ($this->manager === null) {
|
||||||
|
throw new RuntimeException('PreviewService requires Intervention Image.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$disk = Storage::disk('local');
|
||||||
|
if (! $disk->exists($sourcePath)) {
|
||||||
|
return $this->generatePlaceholder($uploadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$absolute = $disk->path($sourcePath);
|
||||||
|
$previewPath = "tmp/drafts/{$uploadId}/preview.webp";
|
||||||
|
$thumbPath = "tmp/drafts/{$uploadId}/thumb.webp";
|
||||||
|
|
||||||
|
$preview = $this->manager->read($absolute)->scaleDown(1280, 1280);
|
||||||
|
$thumb = $this->manager->read($absolute)->cover(320, 320);
|
||||||
|
|
||||||
|
$previewEncoded = (string) $preview->encode(new \Intervention\Image\Encoders\WebpEncoder(85));
|
||||||
|
$thumbEncoded = (string) $thumb->encode(new \Intervention\Image\Encoders\WebpEncoder(82));
|
||||||
|
|
||||||
|
$disk->put($previewPath, $previewEncoded);
|
||||||
|
$disk->put($thumbPath, $thumbEncoded);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'preview_path' => $previewPath,
|
||||||
|
'thumb_path' => $thumbPath,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateFromArchive(string $uploadId, ?string $screenshotPath = null): array
|
||||||
|
{
|
||||||
|
if ($screenshotPath !== null && Storage::disk('local')->exists($screenshotPath)) {
|
||||||
|
return $this->generateFromImage($uploadId, $screenshotPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->generatePlaceholder($uploadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generatePlaceholder(string $uploadId): array
|
||||||
|
{
|
||||||
|
$disk = Storage::disk('local');
|
||||||
|
$previewPath = "tmp/drafts/{$uploadId}/preview.webp";
|
||||||
|
$thumbPath = "tmp/drafts/{$uploadId}/thumb.webp";
|
||||||
|
|
||||||
|
// 1x1 transparent webp
|
||||||
|
$tinyWebp = base64_decode('UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAAAfQ//73v/+BiOh/AAA=');
|
||||||
|
$disk->put($previewPath, $tinyWebp ?: '');
|
||||||
|
$disk->put($thumbPath, $tinyWebp ?: '');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'preview_path' => $previewPath,
|
||||||
|
'thumb_path' => $thumbPath,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
105
app/Services/Upload/TagAnalysisService.php
Normal file
105
app/Services/Upload/TagAnalysisService.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Upload;
|
||||||
|
|
||||||
|
use App\Services\TagNormalizer;
|
||||||
|
|
||||||
|
final class TagAnalysisService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly TagNormalizer $normalizer)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{tag:string,confidence:float,source:string}>
|
||||||
|
*/
|
||||||
|
public function analyze(string $filename, ?string $previewPath, ?string $categoryContext): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($this->extractFilenameTags($filename) as $tag) {
|
||||||
|
$results[] = [
|
||||||
|
'tag' => $tag,
|
||||||
|
'confidence' => 0.72,
|
||||||
|
'source' => 'filename',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($previewPath !== null && $previewPath !== '') {
|
||||||
|
// Stub AI output for now (real model integration can replace this later)
|
||||||
|
$results[] = [
|
||||||
|
'tag' => 'ai-detected',
|
||||||
|
'confidence' => 0.66,
|
||||||
|
'source' => 'ai',
|
||||||
|
];
|
||||||
|
$results[] = [
|
||||||
|
'tag' => 'visual-content',
|
||||||
|
'confidence' => 0.61,
|
||||||
|
'source' => 'ai',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($categoryContext !== null && $categoryContext !== '') {
|
||||||
|
$normalized = $this->normalizer->normalize($categoryContext);
|
||||||
|
if ($normalized !== '') {
|
||||||
|
$results[] = [
|
||||||
|
'tag' => $normalized,
|
||||||
|
'confidence' => 0.60,
|
||||||
|
'source' => 'manual',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->dedupe($results);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function extractFilenameTags(string $filename): array
|
||||||
|
{
|
||||||
|
$base = pathinfo($filename, PATHINFO_FILENAME) ?: $filename;
|
||||||
|
$parts = preg_split('/[\s._\-]+/', mb_strtolower($base, 'UTF-8')) ?: [];
|
||||||
|
|
||||||
|
$tags = [];
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$normalized = $this->normalizer->normalize((string) $part);
|
||||||
|
if ($normalized !== '' && mb_strlen($normalized, 'UTF-8') >= 3) {
|
||||||
|
$tags[] = $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{tag:string,confidence:float,source:string}> $rows
|
||||||
|
* @return array<int, array{tag:string,confidence:float,source:string}>
|
||||||
|
*/
|
||||||
|
private function dedupe(array $rows): array
|
||||||
|
{
|
||||||
|
$best = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$tag = $this->normalizer->normalize((string) ($row['tag'] ?? ''));
|
||||||
|
if ($tag === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$confidence = (float) ($row['confidence'] ?? 0.0);
|
||||||
|
$source = (string) ($row['source'] ?? 'manual');
|
||||||
|
|
||||||
|
if (! isset($best[$tag]) || $best[$tag]['confidence'] < $confidence) {
|
||||||
|
$best[$tag] = [
|
||||||
|
'tag' => $tag,
|
||||||
|
'confidence' => max(0.0, min(1.0, $confidence)),
|
||||||
|
'source' => in_array($source, ['ai', 'filename', 'manual'], true) ? $source : 'manual',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($best);
|
||||||
|
}
|
||||||
|
}
|
||||||
191
app/Services/Upload/UploadDraftService.php
Normal file
191
app/Services/Upload/UploadDraftService.php
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Upload;
|
||||||
|
|
||||||
|
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
|
||||||
|
use Illuminate\Contracts\Filesystem\Filesystem as FilesystemContract;
|
||||||
|
use Illuminate\Filesystem\FilesystemManager;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class UploadDraftService implements UploadDraftServiceInterface
|
||||||
|
{
|
||||||
|
protected FilesystemManager $filesystem;
|
||||||
|
protected FilesystemContract $disk;
|
||||||
|
protected string $diskName;
|
||||||
|
protected string $basePath = 'tmp/drafts';
|
||||||
|
|
||||||
|
public function __construct(FilesystemManager $filesystem, string $diskName = 'local')
|
||||||
|
{
|
||||||
|
$this->filesystem = $filesystem;
|
||||||
|
$this->diskName = $diskName;
|
||||||
|
$this->disk = $this->filesystem->disk($this->diskName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createDraft(array $attributes = []): array
|
||||||
|
{
|
||||||
|
$id = (string) Str::uuid();
|
||||||
|
$path = trim($this->basePath, '/') . '/' . $id;
|
||||||
|
|
||||||
|
if (! $this->disk->exists($path)) {
|
||||||
|
$this->disk->makeDirectory($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = array_merge(['id' => $id, 'created_at' => Carbon::now()->toISOString()], $attributes);
|
||||||
|
|
||||||
|
DB::table('uploads')->insert([
|
||||||
|
'id' => $id,
|
||||||
|
'user_id' => (int) ($attributes['user_id'] ?? 0),
|
||||||
|
'type' => (string) ($attributes['type'] ?? 'image'),
|
||||||
|
'status' => 'draft',
|
||||||
|
'moderation_status' => 'pending',
|
||||||
|
'processing_state' => 'pending_scan',
|
||||||
|
'expires_at' => null,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->writeMeta($id, $meta);
|
||||||
|
|
||||||
|
return ['id' => $id, 'path' => $path, 'meta' => $meta];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeMainFile(string $draftId, UploadedFile $file): array
|
||||||
|
{
|
||||||
|
$dir = trim($this->basePath, '/') . '/' . $draftId . '/main';
|
||||||
|
if (! $this->disk->exists($dir)) {
|
||||||
|
$this->disk->makeDirectory($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = time() . '_' . preg_replace('/[^A-Za-z0-9_\.-]/', '_', $file->getClientOriginalName());
|
||||||
|
$storedPath = $this->disk->putFileAs($dir, $file, $filename);
|
||||||
|
|
||||||
|
$size = $this->safeSize($storedPath, $file);
|
||||||
|
$mime = $file->getClientMimeType() ?? $this->safeMimeType($storedPath);
|
||||||
|
$hash = $this->calculateHash($file->getRealPath() ?: $storedPath);
|
||||||
|
|
||||||
|
$info = ['path' => $storedPath, 'size' => $size, 'mime' => $mime, 'hash' => $hash];
|
||||||
|
|
||||||
|
$meta = $this->readMeta($draftId);
|
||||||
|
$meta['main_file'] = $info;
|
||||||
|
$this->writeMeta($draftId, $meta);
|
||||||
|
|
||||||
|
DB::table('upload_files')->insert([
|
||||||
|
'upload_id' => $draftId,
|
||||||
|
'path' => $storedPath,
|
||||||
|
'type' => 'main',
|
||||||
|
'hash' => $hash,
|
||||||
|
'size' => $size,
|
||||||
|
'mime' => $mime,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $info;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeScreenshot(string $draftId, UploadedFile $file): array
|
||||||
|
{
|
||||||
|
$dir = trim($this->basePath, '/') . '/' . $draftId . '/screenshots';
|
||||||
|
if (! $this->disk->exists($dir)) {
|
||||||
|
$this->disk->makeDirectory($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = time() . '_' . preg_replace('/[^A-Za-z0-9_\.-]/', '_', $file->getClientOriginalName());
|
||||||
|
$storedPath = $this->disk->putFileAs($dir, $file, $filename);
|
||||||
|
|
||||||
|
$size = $this->safeSize($storedPath, $file);
|
||||||
|
$mime = $file->getClientMimeType() ?? $this->safeMimeType($storedPath);
|
||||||
|
$hash = $this->calculateHash($file->getRealPath() ?: $storedPath);
|
||||||
|
|
||||||
|
$info = ['path' => $storedPath, 'size' => $size, 'mime' => $mime, 'hash' => $hash];
|
||||||
|
|
||||||
|
$meta = $this->readMeta($draftId);
|
||||||
|
$meta['screenshots'][] = $info;
|
||||||
|
$this->writeMeta($draftId, $meta);
|
||||||
|
|
||||||
|
DB::table('upload_files')->insert([
|
||||||
|
'upload_id' => $draftId,
|
||||||
|
'path' => $storedPath,
|
||||||
|
'type' => 'screenshot',
|
||||||
|
'hash' => $hash,
|
||||||
|
'size' => $size,
|
||||||
|
'mime' => $mime,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $info;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function calculateHash(string $filePath): string
|
||||||
|
{
|
||||||
|
// If path points to a local filesystem file
|
||||||
|
if (is_file($filePath)) {
|
||||||
|
return hash_file('sha256', $filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If path is a storage-relative path
|
||||||
|
if ($this->disk->exists($filePath)) {
|
||||||
|
$contents = $this->disk->get($filePath);
|
||||||
|
return hash('sha256', $contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException('File not found for hashing: ' . $filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setExpiration(string $draftId, ?Carbon $expiresAt = null): bool
|
||||||
|
{
|
||||||
|
$meta = $this->readMeta($draftId);
|
||||||
|
$meta['expires_at'] = $expiresAt?->toISOString();
|
||||||
|
$this->writeMeta($draftId, $meta);
|
||||||
|
|
||||||
|
DB::table('uploads')->where('id', $draftId)->update([
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function metaPath(string $draftId): string
|
||||||
|
{
|
||||||
|
return trim($this->basePath, '/') . '/' . $draftId . '/meta.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function readMeta(string $draftId): array
|
||||||
|
{
|
||||||
|
$path = $this->metaPath($draftId);
|
||||||
|
if (! $this->disk->exists($path)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = $this->disk->get($path);
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
return is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function writeMeta(string $draftId, array $meta): void
|
||||||
|
{
|
||||||
|
$path = $this->metaPath($draftId);
|
||||||
|
$this->disk->put($path, json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function safeSize(string $storedPath, UploadedFile $file): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->disk->size($storedPath);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return (int) $file->getSize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function safeMimeType(string $storedPath): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->disk->mimeType($storedPath);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Services/Uploads/UploadAuditService.php
Normal file
19
app/Services/Uploads/UploadAuditService.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Uploads;
|
||||||
|
|
||||||
|
use App\Repositories\Uploads\AuditLogRepository;
|
||||||
|
|
||||||
|
final class UploadAuditService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AuditLogRepository $repository)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function log(?int $userId, string $action, string $ip, array $meta = []): void
|
||||||
|
{
|
||||||
|
$this->repository->log($userId, $action, $ip, $meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/Services/Uploads/UploadCancelService.php
Normal file
84
app/Services/Uploads/UploadCancelService.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Uploads;
|
||||||
|
|
||||||
|
use App\Repositories\Uploads\UploadSessionRepository;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class UploadCancelService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UploadSessionRepository $sessions,
|
||||||
|
private readonly UploadStorageService $storage,
|
||||||
|
private readonly UploadAuditService $audit
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(string $sessionId, int $userId, string $ip): array
|
||||||
|
{
|
||||||
|
$session = $this->sessions->getOrFail($sessionId);
|
||||||
|
|
||||||
|
$lockSeconds = (int) config('uploads.chunk.lock_seconds', 10);
|
||||||
|
$lockWait = (int) config('uploads.chunk.lock_wait_seconds', 5);
|
||||||
|
$lock = Cache::lock('uploads:cancel:' . $sessionId, $lockSeconds);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$lock->block($lockWait);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->audit->log($userId, 'upload_cancel_locked', $ip, [
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
]);
|
||||||
|
throw new RuntimeException('Upload is busy. Please retry.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (in_array($session->status, [UploadSessionStatus::CANCELLED, UploadSessionStatus::PROCESSED, UploadSessionStatus::QUARANTINED], true)) {
|
||||||
|
$this->audit->log($userId, 'upload_cancel_noop', $ip, [
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'status' => $session->status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'status' => $session->status,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->safeDeleteTmp($session->tempPath);
|
||||||
|
|
||||||
|
$this->sessions->updateStatus($sessionId, UploadSessionStatus::CANCELLED);
|
||||||
|
$this->sessions->updateProgress($sessionId, 0);
|
||||||
|
$this->sessions->updateFailureReason($sessionId, 'cancelled');
|
||||||
|
|
||||||
|
$this->audit->log($userId, 'upload_cancelled', $ip, [
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'status' => UploadSessionStatus::CANCELLED,
|
||||||
|
];
|
||||||
|
} finally {
|
||||||
|
optional($lock)->release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeDeleteTmp(string $path): void
|
||||||
|
{
|
||||||
|
$tmpRoot = $this->storage->sectionPath('tmp');
|
||||||
|
$realRoot = realpath($tmpRoot);
|
||||||
|
$realPath = realpath($path);
|
||||||
|
|
||||||
|
if (! $realRoot || ! $realPath || strpos($realPath, $realRoot) !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File::exists($realPath)) {
|
||||||
|
File::delete($realPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
204
app/Services/Uploads/UploadChunkService.php
Normal file
204
app/Services/Uploads/UploadChunkService.php
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Uploads;
|
||||||
|
|
||||||
|
use App\DTOs\Uploads\UploadChunkResult;
|
||||||
|
use App\Repositories\Uploads\UploadSessionRepository;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class UploadChunkService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UploadStorageService $storage,
|
||||||
|
private readonly UploadSessionRepository $sessions,
|
||||||
|
private readonly UploadAuditService $audit
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function appendChunk(string $sessionId, string $chunkPath, int $offset, int $chunkSize, int $totalSize, int $userId, string $ip): UploadChunkResult
|
||||||
|
{
|
||||||
|
$session = $this->sessions->getOrFail($sessionId);
|
||||||
|
|
||||||
|
$this->ensureTmpPath($session->tempPath);
|
||||||
|
$this->ensureWritable($session->tempPath);
|
||||||
|
$this->ensureChunkReadable($chunkPath, $chunkSize);
|
||||||
|
$this->ensureLimits($totalSize, $chunkSize);
|
||||||
|
|
||||||
|
$lockSeconds = (int) config('uploads.chunk.lock_seconds', 10);
|
||||||
|
$lockWait = (int) config('uploads.chunk.lock_wait_seconds', 5);
|
||||||
|
$lock = Cache::lock('uploads:chunk:' . $sessionId, $lockSeconds);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$lock->block($lockWait);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->audit->log($userId, 'upload_chunk_locked', $ip, [
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
]);
|
||||||
|
throw new RuntimeException('Upload is busy. Please retry.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$currentSize = (int) filesize($session->tempPath);
|
||||||
|
|
||||||
|
if ($offset > $currentSize) {
|
||||||
|
$this->audit->log($userId, 'upload_chunk_offset_mismatch', $ip, [
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'offset' => $offset,
|
||||||
|
'current_size' => $currentSize,
|
||||||
|
]);
|
||||||
|
throw new RuntimeException('Invalid chunk offset.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($offset < $currentSize) {
|
||||||
|
if ($offset + $chunkSize <= $currentSize) {
|
||||||
|
return $this->finalizeResult($sessionId, $totalSize, $currentSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->audit->log($userId, 'upload_chunk_overlap', $ip, [
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'offset' => $offset,
|
||||||
|
'current_size' => $currentSize,
|
||||||
|
]);
|
||||||
|
throw new RuntimeException('Chunk overlap detected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$written = $this->appendToFile($session->tempPath, $chunkPath, $offset, $chunkSize);
|
||||||
|
$newSize = $currentSize + $written;
|
||||||
|
|
||||||
|
if ($newSize > $totalSize) {
|
||||||
|
$this->audit->log($userId, 'upload_chunk_size_exceeded', $ip, [
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'new_size' => $newSize,
|
||||||
|
'total_size' => $totalSize,
|
||||||
|
]);
|
||||||
|
throw new RuntimeException('Upload exceeded expected size.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sessions->updateStatus($sessionId, UploadSessionStatus::TMP);
|
||||||
|
$result = $this->finalizeResult($sessionId, $totalSize, $newSize);
|
||||||
|
|
||||||
|
$this->audit->log($userId, 'upload_chunk_appended', $ip, [
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'received_bytes' => $newSize,
|
||||||
|
'total_size' => $totalSize,
|
||||||
|
'progress' => $result->progress,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
} finally {
|
||||||
|
optional($lock)->release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function finalizeResult(string $sessionId, int $totalSize, int $currentSize): UploadChunkResult
|
||||||
|
{
|
||||||
|
$progress = $totalSize > 0 ? (int) floor(($currentSize / $totalSize) * 100) : 0;
|
||||||
|
$progress = min(90, max(0, $progress));
|
||||||
|
$this->sessions->updateProgress($sessionId, $progress);
|
||||||
|
|
||||||
|
return new UploadChunkResult(
|
||||||
|
$sessionId,
|
||||||
|
UploadSessionStatus::TMP,
|
||||||
|
$currentSize,
|
||||||
|
$totalSize,
|
||||||
|
$progress
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureTmpPath(string $path): void
|
||||||
|
{
|
||||||
|
$tmpRoot = $this->storage->sectionPath('tmp');
|
||||||
|
$realRoot = realpath($tmpRoot);
|
||||||
|
$realPath = realpath($path);
|
||||||
|
|
||||||
|
if (! $realRoot || ! $realPath || strpos($realPath, $realRoot) !== 0) {
|
||||||
|
throw new RuntimeException('Invalid temp path.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureWritable(string $path): void
|
||||||
|
{
|
||||||
|
if (! File::exists($path)) {
|
||||||
|
File::put($path, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_writable($path)) {
|
||||||
|
throw new RuntimeException('Upload path not writable.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureLimits(int $totalSize, int $chunkSize): void
|
||||||
|
{
|
||||||
|
$maxBytes = (int) config('uploads.max_size_mb', 0) * 1024 * 1024;
|
||||||
|
if ($maxBytes > 0 && $totalSize > $maxBytes) {
|
||||||
|
throw new RuntimeException('Upload exceeds max size.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxChunk = (int) config('uploads.chunk.max_bytes', 0);
|
||||||
|
if ($maxChunk > 0 && $chunkSize > $maxChunk) {
|
||||||
|
throw new RuntimeException('Chunk exceeds max size.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureChunkReadable(string $chunkPath, int $chunkSize): void
|
||||||
|
{
|
||||||
|
$exists = is_file($chunkPath);
|
||||||
|
$readable = $exists ? is_readable($chunkPath) : false;
|
||||||
|
$actualSize = $exists ? (int) @filesize($chunkPath) : null;
|
||||||
|
|
||||||
|
if (! $exists || ! $readable) {
|
||||||
|
logger()->warning('Upload chunk unreadable or missing', [
|
||||||
|
'chunk_path' => $chunkPath,
|
||||||
|
'expected_size' => $chunkSize,
|
||||||
|
'exists' => $exists,
|
||||||
|
'readable' => $readable,
|
||||||
|
'actual_size' => $actualSize,
|
||||||
|
]);
|
||||||
|
throw new RuntimeException('Upload chunk missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($actualSize !== $chunkSize) {
|
||||||
|
logger()->warning('Upload chunk size mismatch', [
|
||||||
|
'chunk_path' => $chunkPath,
|
||||||
|
'expected_size' => $chunkSize,
|
||||||
|
'actual_size' => $actualSize,
|
||||||
|
]);
|
||||||
|
throw new RuntimeException('Chunk size mismatch.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function appendToFile(string $targetPath, string $chunkPath, int $offset, int $chunkSize): int
|
||||||
|
{
|
||||||
|
$in = fopen($chunkPath, 'rb');
|
||||||
|
if (! $in) {
|
||||||
|
throw new RuntimeException('Unable to read upload chunk.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = fopen($targetPath, 'c+b');
|
||||||
|
if (! $out) {
|
||||||
|
fclose($in);
|
||||||
|
throw new RuntimeException('Unable to write upload chunk.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fseek($out, $offset) !== 0) {
|
||||||
|
fclose($in);
|
||||||
|
fclose($out);
|
||||||
|
throw new RuntimeException('Failed to seek in upload file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$written = stream_copy_to_stream($in, $out, $chunkSize);
|
||||||
|
fflush($out);
|
||||||
|
fclose($in);
|
||||||
|
fclose($out);
|
||||||
|
|
||||||
|
if ($written === false || (int) $written !== $chunkSize) {
|
||||||
|
throw new RuntimeException('Incomplete chunk write.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $written;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/Services/Uploads/UploadDerivativesService.php
Normal file
89
app/Services/Uploads/UploadDerivativesService.php
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Uploads;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Intervention\Image\ImageManager as ImageManager;
|
||||||
|
use Intervention\Image\Interfaces\ImageInterface as InterventionImageInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class UploadDerivativesService
|
||||||
|
{
|
||||||
|
private bool $imageAvailable = false;
|
||||||
|
private ?ImageManager $manager = null;
|
||||||
|
|
||||||
|
public function __construct(private readonly UploadStorageService $storage)
|
||||||
|
{
|
||||||
|
// Intervention Image v3 uses ImageManager; instantiate appropriate driver
|
||||||
|
try {
|
||||||
|
$this->manager = extension_loaded('gd') ? ImageManager::gd() : ImageManager::imagick();
|
||||||
|
$this->imageAvailable = true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
logger()->warning('Intervention Image present but configuration failed: ' . $e->getMessage());
|
||||||
|
$this->imageAvailable = false;
|
||||||
|
$this->manager = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeOriginal(string $sourcePath, string $hash): string
|
||||||
|
{
|
||||||
|
$this->assertImageAvailable();
|
||||||
|
|
||||||
|
$dir = $this->storage->ensureHashDirectory('originals', $hash);
|
||||||
|
$target = $dir . DIRECTORY_SEPARATOR . 'orig.webp';
|
||||||
|
$quality = (int) config('uploads.quality', 85);
|
||||||
|
|
||||||
|
/** @var InterventionImageInterface $img */
|
||||||
|
$img = $this->manager->read($sourcePath);
|
||||||
|
$encoder = new \Intervention\Image\Encoders\WebpEncoder($quality);
|
||||||
|
$encoded = (string) $img->encode($encoder);
|
||||||
|
File::put($target, $encoded);
|
||||||
|
|
||||||
|
return $target;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generatePublicDerivatives(string $sourcePath, string $hash): array
|
||||||
|
{
|
||||||
|
$this->assertImageAvailable();
|
||||||
|
$quality = (int) config('uploads.quality', 85);
|
||||||
|
$variants = (array) config('uploads.derivatives', []);
|
||||||
|
$dir = $this->storage->publicHashDirectory($hash);
|
||||||
|
$written = [];
|
||||||
|
|
||||||
|
foreach ($variants as $variant => $options) {
|
||||||
|
$variant = (string) $variant;
|
||||||
|
$path = $dir . DIRECTORY_SEPARATOR . $variant . '.webp';
|
||||||
|
|
||||||
|
/** @var InterventionImageInterface $img */
|
||||||
|
$img = $this->manager->read($sourcePath);
|
||||||
|
|
||||||
|
if (isset($options['size'])) {
|
||||||
|
$size = (int) $options['size'];
|
||||||
|
$out = $img->cover($size, $size);
|
||||||
|
} else {
|
||||||
|
$max = (int) ($options['max'] ?? 0);
|
||||||
|
if ($max <= 0) {
|
||||||
|
$max = 2560;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = $img->scaleDown($max, $max);
|
||||||
|
}
|
||||||
|
|
||||||
|
$encoder = new \Intervention\Image\Encoders\WebpEncoder($quality);
|
||||||
|
$encoded = (string) $out->encode($encoder);
|
||||||
|
File::put($path, $encoded);
|
||||||
|
$written[$variant] = $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $written;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertImageAvailable(): void
|
||||||
|
{
|
||||||
|
if (! $this->imageAvailable) {
|
||||||
|
throw new RuntimeException('Intervention Image is not available.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Services/Uploads/UploadHashService.php
Normal file
21
app/Services/Uploads/UploadHashService.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Uploads;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class UploadHashService
|
||||||
|
{
|
||||||
|
public function hashFile(string $path): string
|
||||||
|
{
|
||||||
|
$hash = hash_file('sha256', $path);
|
||||||
|
|
||||||
|
if ($hash === false) {
|
||||||
|
throw new RuntimeException('Failed to hash upload file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user