Merge branch 'feature/NovaDesignImplement' into develop
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
|
||||
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_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.
|
||||
|
||||
## 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
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
|
||||
@@ -6,11 +6,11 @@ class Banner
|
||||
{
|
||||
public static function ShowResponsiveAd()
|
||||
{
|
||||
echo '<div class="responsive_ad">';
|
||||
echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>';
|
||||
echo '<ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6457864535683080" data-ad-slot="9918154676" data-ad-format="auto"></ins>';
|
||||
echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
|
||||
echo '</div>';
|
||||
#echo '<div class="responsive_ad">';
|
||||
#echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>';
|
||||
#echo '<ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6457864535683080" data-ad-slot="9918154676" data-ad-format="auto"></ins>';
|
||||
#echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
|
||||
#echo '</div>';
|
||||
}
|
||||
|
||||
public static function ShowBanner300x250()
|
||||
|
||||
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([
|
||||
'user_id' => $legacyId,
|
||||
'bio' => $row->about_me ?: $row->description ?: null,
|
||||
'about' => $row->about_me ?: $row->description ?: null,
|
||||
'avatar' => $row->picture ?: null,
|
||||
'cover_image' => $row->cover_art ?: null,
|
||||
'country' => $row->country ?: null,
|
||||
@@ -115,15 +115,7 @@ class ImportLegacyUsers extends Command
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if (!empty($row->web)) {
|
||||
DB::table('user_social_links')->insert([
|
||||
'user_id' => $legacyId,
|
||||
'platform' => 'website',
|
||||
'url' => $row->web,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
// Do not duplicate `website` into `user_social_links` — keep canonical site in `user_profiles.website`.
|
||||
|
||||
DB::table('user_statistics')->insert([
|
||||
'user_id' => $legacyId,
|
||||
|
||||
@@ -6,6 +6,12 @@ use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||
use App\Console\Commands\ImportLegacyUsers;
|
||||
use App\Console\Commands\ImportCategories;
|
||||
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
|
||||
{
|
||||
@@ -18,7 +24,14 @@ class Kernel extends ConsoleKernel
|
||||
ImportLegacyUsers::class,
|
||||
ImportCategories::class,
|
||||
MigrateFeaturedWorks::class,
|
||||
\App\Console\Commands\AvatarsMigrate::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
|
||||
{
|
||||
// $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;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Artworks\ArtworkCreateRequest;
|
||||
use App\Http\Resources\ArtworkListResource;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\Artworks\ArtworkDraftService;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ArtworkController extends Controller
|
||||
{
|
||||
@@ -17,6 +20,27 @@ class ArtworkController extends Controller
|
||||
$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}
|
||||
* Returns a single public artwork resource by slug.
|
||||
|
||||
165
app/Http/Controllers/Api/ArtworkTagController.php
Normal file
165
app/Http/Controllers/Api/ArtworkTagController.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?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\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use App\Jobs\AutoTagArtworkJob;
|
||||
|
||||
final class ArtworkTagController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TagService $tags,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(int $id): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::query()->findOrFail($id);
|
||||
$this->authorizeOrNotFound(request()->user(), $artwork);
|
||||
|
||||
$queueConnection = (string) config('queue.default', 'sync');
|
||||
$visionEnabled = (bool) config('vision.enabled', true);
|
||||
|
||||
$queuedCount = 0;
|
||||
$failedCount = 0;
|
||||
|
||||
if (in_array($queueConnection, ['database', 'redis'], true)) {
|
||||
try {
|
||||
$queuedCount = (int) DB::table('jobs')
|
||||
->where('payload', 'like', '%AutoTagArtworkJob%')
|
||||
->where('payload', 'like', '%' . $artwork->id . '%')
|
||||
->count();
|
||||
} catch (\Throwable) {
|
||||
$queuedCount = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$failedCount = (int) DB::table('failed_jobs')
|
||||
->where('payload', 'like', '%AutoTagArtworkJob%')
|
||||
->where('payload', 'like', '%' . $artwork->id . '%')
|
||||
->count();
|
||||
} catch (\Throwable) {
|
||||
$failedCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
$triggered = false;
|
||||
$shouldTrigger = request()->boolean('trigger', false);
|
||||
if ($shouldTrigger && $visionEnabled && ! empty($artwork->hash) && $queuedCount === 0) {
|
||||
AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash);
|
||||
$triggered = true;
|
||||
$queuedCount = max(1, $queuedCount);
|
||||
}
|
||||
|
||||
$tags = $artwork->tags()
|
||||
->select('tags.id', 'tags.name', 'tags.slug')
|
||||
->withPivot(['source', 'confidence'])
|
||||
->orderByDesc('artwork_tag.confidence')
|
||||
->get()
|
||||
->map(static function ($tag): array {
|
||||
$source = (string) ($tag->pivot->source ?? 'manual');
|
||||
return [
|
||||
'id' => (int) $tag->id,
|
||||
'name' => (string) $tag->name,
|
||||
'slug' => (string) $tag->slug,
|
||||
'source' => $source,
|
||||
'confidence' => (float) ($tag->pivot->confidence ?? 0),
|
||||
'is_ai' => $source === 'ai',
|
||||
];
|
||||
})
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'vision_enabled' => $visionEnabled,
|
||||
'tags' => $tags,
|
||||
'ai_tags' => $tags->where('is_ai', true)->values(),
|
||||
'debug' => [
|
||||
'queue_connection' => $queueConnection,
|
||||
'queued_jobs' => $queuedCount,
|
||||
'failed_jobs' => $failedCount,
|
||||
'triggered' => $triggered,
|
||||
'ai_tag_count' => (int) $tags->where('is_ai', true)->count(),
|
||||
'total_tag_count' => (int) $tags->count(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
547
app/Http/Controllers/Api/UploadController.php
Normal file
547
app/Http/Controllers/Api/UploadController.php
Normal file
@@ -0,0 +1,547 @@
|
||||
<?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;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
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 {
|
||||
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId) {
|
||||
if ((bool) config('uploads.queue_derivatives', false)) {
|
||||
GenerateDerivativesJob::dispatch($sessionId, $validated->hash, $artworkId)->afterCommit();
|
||||
return 'queued';
|
||||
}
|
||||
|
||||
$pipeline->processAndPublish($sessionId, $validated->hash, $artworkId);
|
||||
|
||||
// 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,
|
||||
], 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();
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => ['nullable', 'string', 'max:150'],
|
||||
'description' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
if (ctype_digit($id)) {
|
||||
$artworkId = (int) $id;
|
||||
$artwork = Artwork::query()->find($artworkId);
|
||||
if (! $artwork) {
|
||||
return response()->json(['message' => 'Artwork not found.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ((int) $artwork->user_id !== (int) $user->id) {
|
||||
return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$title = trim((string) ($validated['title'] ?? $artwork->title ?? ''));
|
||||
if ($title === '') {
|
||||
$title = 'Untitled artwork';
|
||||
}
|
||||
|
||||
$slugBase = Str::slug($title);
|
||||
if ($slugBase === '') {
|
||||
$slugBase = 'artwork';
|
||||
}
|
||||
|
||||
$slug = $slugBase;
|
||||
$suffix = 2;
|
||||
while (Artwork::query()->where('slug', $slug)->where('id', '!=', $artwork->id)->exists()) {
|
||||
$slug = $slugBase . '-' . $suffix;
|
||||
$suffix++;
|
||||
}
|
||||
|
||||
$artwork->title = $title;
|
||||
if (array_key_exists('description', $validated)) {
|
||||
$artwork->description = $validated['description'];
|
||||
}
|
||||
$artwork->slug = $slug;
|
||||
$artwork->is_public = true;
|
||||
$artwork->is_approved = true;
|
||||
$artwork->published_at = now();
|
||||
$artwork->save();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'published',
|
||||
'slug' => (string) $artwork->slug,
|
||||
'published_at' => optional($artwork->published_at)->toISOString(),
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
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(),
|
||||
], 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
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\CategoryPageController;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ArtworkIndexRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Services\Recommendations\SimilarArtworksService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
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);
|
||||
}
|
||||
|
||||
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\ContentType;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
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();
|
||||
if (! $contentType) {
|
||||
@@ -24,38 +25,51 @@ class CategoryPageController extends Controller
|
||||
$page_title = $contentType->name;
|
||||
$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(
|
||||
'contentType',
|
||||
'rootCategories',
|
||||
'artworks',
|
||||
'page_title',
|
||||
'page_meta_description'
|
||||
));
|
||||
}
|
||||
|
||||
$segments = array_filter(explode('/', $categoryPath));
|
||||
if (empty($segments)) {
|
||||
$slugs = array_values(array_map('strtolower', $segments));
|
||||
if (empty($slugs)) {
|
||||
return redirect('/browse-categories');
|
||||
}
|
||||
|
||||
// Traverse categories by slug path within the content type
|
||||
$current = Category::where('content_type_id', $contentType->id)
|
||||
->whereNull('parent_id')
|
||||
->where('slug', strtolower(array_shift($segments)))
|
||||
->first();
|
||||
// If the first slug exists but under a different content type, redirect to its canonical URL
|
||||
$firstSlug = $slugs[0];
|
||||
$globalRoot = Category::whereNull('parent_id')->where('slug', $firstSlug)->first();
|
||||
if ($globalRoot && $globalRoot->contentType && $globalRoot->contentType->slug !== strtolower($contentType->slug)) {
|
||||
$redirectPath = '/' . $globalRoot->contentType->slug . '/' . implode('/', $slugs);
|
||||
return redirect($redirectPath, 301);
|
||||
}
|
||||
|
||||
|
||||
if (! $current) {
|
||||
// Resolve category by path using the helper that validates parent chain and content type
|
||||
$category = Category::findByPath($contentType->slug, $slugs);
|
||||
if (! $category) {
|
||||
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();
|
||||
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
|
||||
|
||||
@@ -71,21 +85,23 @@ class CategoryPageController extends Controller
|
||||
$category->load('children');
|
||||
$gather($category);
|
||||
|
||||
// Load artworks that are attached to any of these categories
|
||||
$query = Artwork::whereHas('categories', function ($q) use ($collected) {
|
||||
$q->whereIn('categories.id', $collected);
|
||||
})->published()->public();
|
||||
|
||||
// Paginate results
|
||||
// Load artworks via ArtworkService to support arbitrary-depth category paths
|
||||
$perPage = 40;
|
||||
$artworks = $query->orderBy('published_at', 'desc')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
try {
|
||||
$service = app(ArtworkService::class);
|
||||
// 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_meta_description = $category->description ?? ($contentType->name . ' artworks on Skinbase');
|
||||
$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(
|
||||
'contentType',
|
||||
'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;
|
||||
|
||||
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\Models\Artwork;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
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();
|
||||
$this->authorize('update', $artwork);
|
||||
$artwork = $request->artwork();
|
||||
|
||||
return view('artworks.edit', [
|
||||
'artwork' => $artwork,
|
||||
@@ -38,8 +38,7 @@ class ArtworkController extends Controller
|
||||
|
||||
public function update(UpdateArtworkRequest $request, int $id): RedirectResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->whereKey($id)->firstOrFail();
|
||||
$this->authorize('update', $artwork);
|
||||
$artwork = $request->artwork();
|
||||
|
||||
$data = $request->validated();
|
||||
|
||||
@@ -83,10 +82,9 @@ class ArtworkController extends Controller
|
||||
->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();
|
||||
$this->authorize('delete', $artwork);
|
||||
$artwork = $request->artwork();
|
||||
|
||||
// Best-effort remove stored file.
|
||||
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)
|
||||
{
|
||||
// Legacy group mapping: Photography => id 3
|
||||
$group = 'Photography';
|
||||
$id = 3;
|
||||
// Determine the requested content type from the first URL segment (photography|wallpapers|skins)
|
||||
$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;
|
||||
try {
|
||||
if (Schema::hasTable('artworks_categories')) {
|
||||
if ($id !== null && Schema::hasTable('artworks_categories')) {
|
||||
$category = DB::table('artworks_categories')
|
||||
->select('category_name', 'rootid', 'section_id', 'description', 'category_id')
|
||||
->where('category_id', $id)
|
||||
@@ -37,22 +47,41 @@ class PhotographyController extends Controller
|
||||
$category = null;
|
||||
}
|
||||
|
||||
$page_title = $category->category_name ?? 'Photography';
|
||||
$tidy = $category->description ?? null;
|
||||
// Page title and description: prefer legacy category when present, otherwise use ContentType data
|
||||
$ct = ContentType::where('slug', $contentSlug)->first();
|
||||
$page_title = $category->category_name ?? ($ct->name ?? ucfirst($contentSlug));
|
||||
$tidy = $category->description ?? ($ct->description ?? null);
|
||||
|
||||
$perPage = 40;
|
||||
|
||||
// Use ArtworkService to get artworks for the content type 'photography'
|
||||
// Load artworks for the requested content type using standard pagination
|
||||
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) {
|
||||
$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();
|
||||
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();
|
||||
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();
|
||||
@@ -62,20 +91,35 @@ class PhotographyController extends Controller
|
||||
$subcategories = collect();
|
||||
}
|
||||
|
||||
// Fallback to authoritative categories table when legacy table is missing/empty
|
||||
if (! $subcategories || $subcategories->count() === 0) {
|
||||
$ct = ContentType::where('slug', 'photography')->first();
|
||||
if ($ct) {
|
||||
$subcategories = $ct->rootCategories()
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->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 {
|
||||
$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.');
|
||||
}
|
||||
} else {
|
||||
$data = $request->only(['real_name','web','country_code','signature','description','about_me']);
|
||||
$user->real_name = $data['real_name'] ?? $user->real_name;
|
||||
$user->web = $data['web'] ?? $user->web;
|
||||
$user->country_code = $data['country_code'] ?? $user->country_code;
|
||||
$user->signature = $data['signature'] ?? $user->signature;
|
||||
$user->description = $data['description'] ?? $user->description;
|
||||
$user->about_me = $data['about_me'] ?? $user->about_me;
|
||||
// Map legacy form fields into the modern schema.
|
||||
$data = $request->only(['name','web','country_code','signature','description','about_me']);
|
||||
|
||||
// Core user column: `name`
|
||||
if (isset($data['name'])) {
|
||||
$user->name = $data['name'] ?? $user->name;
|
||||
}
|
||||
|
||||
// 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');
|
||||
$d2 = $request->input('date2');
|
||||
$d3 = $request->input('date3');
|
||||
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);
|
||||
$user->mlist = $request->has('newsletter') ? 1 : 0;
|
||||
$user->friend_upload_notice = $request->has('friend_upload_notice') ? 1 : 0;
|
||||
$userGender = $request->input('gender', $user->gender);
|
||||
if (!empty($userGender)) {
|
||||
$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')) {
|
||||
$f = $request->file('avatar');
|
||||
$name = $user->id . '.' . $f->getClientOriginalExtension();
|
||||
$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;
|
||||
}
|
||||
|
||||
@@ -67,6 +84,7 @@ class UserController extends Controller
|
||||
$f = $request->file('personal_picture');
|
||||
$name = $user->id . '.' . $f->getClientOriginalExtension();
|
||||
$f->move(public_path('user-picture'), $name);
|
||||
$profileUpdates['cover_image'] = $name;
|
||||
$user->picture = $name;
|
||||
}
|
||||
|
||||
@@ -77,25 +95,28 @@ class UserController extends Controller
|
||||
$user->eicon = $name;
|
||||
}
|
||||
|
||||
// Save core user fields
|
||||
$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.');
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare birth date parts for the legacy form
|
||||
// Prepare birth date parts for the legacy form (initialized — parsed after merging profiles)
|
||||
$birthDay = null;
|
||||
$birthMonth = 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)
|
||||
$countries = collect();
|
||||
@@ -109,6 +130,50 @@ class UserController extends Controller
|
||||
$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', [
|
||||
'user' => $user,
|
||||
'birthDay' => $birthDay,
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkCategory;
|
||||
use App\Http\Requests\Manage\ManageArtworkEditRequest;
|
||||
use App\Http\Requests\Manage\ManageArtworkUpdateRequest;
|
||||
use App\Http\Requests\Manage\ManageArtworkDestroyRequest;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
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 = DB::table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first();
|
||||
if (! $artwork) {
|
||||
abort(404);
|
||||
}
|
||||
$artwork = $request->artwork();
|
||||
|
||||
// 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');
|
||||
@@ -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 = DB::table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first();
|
||||
|
||||
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',
|
||||
]);
|
||||
$existing = $request->artwork();
|
||||
$data = $request->validated();
|
||||
$update = [
|
||||
'name' => $data['name'],
|
||||
'description' => $data['description'] ?? $existing->description,
|
||||
@@ -100,7 +85,7 @@ class ManageController extends Controller
|
||||
$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
|
||||
if (isset($data['section'])) {
|
||||
@@ -114,13 +99,9 @@ class ManageController extends Controller
|
||||
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 = DB::table('artworks')->where('id', (int)$id)->where('user_id', $userId)->first();
|
||||
if (! $artwork) {
|
||||
abort(404);
|
||||
}
|
||||
$artwork = $request->artwork();
|
||||
|
||||
// delete files if present (stored in new storage location)
|
||||
if (!empty($artwork->fname)) {
|
||||
@@ -130,7 +111,7 @@ class ManageController extends Controller
|
||||
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.');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
@@ -24,17 +26,124 @@ class ProfileController extends Controller
|
||||
/**
|
||||
* 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')) {
|
||||
$request->user()->email_verified_at = null;
|
||||
// Core fields
|
||||
$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('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class UpdateArtworkRequest extends FormRequest
|
||||
{
|
||||
private ?Artwork $artwork = null;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -21,4 +44,28 @@ class UpdateArtworkRequest extends FormRequest
|
||||
'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
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'username' => ['sometimes', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
@@ -25,6 +25,21 @@ class ProfileUpdateRequest extends FormRequest
|
||||
'max:255',
|
||||
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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Http\Resources\MissingValue;
|
||||
use App\Services\ThumbnailService;
|
||||
|
||||
class ArtworkListResource extends JsonResource
|
||||
{
|
||||
@@ -48,6 +48,8 @@ class ArtworkListResource extends JsonResource
|
||||
$categoryPath = $primaryCategory->full_slug_path ?? null;
|
||||
}
|
||||
$slugVal = $get('slug');
|
||||
$hash = (string) ($get('hash') ?? '');
|
||||
$thumbExt = (string) ($get('thumb_ext') ?? '');
|
||||
$webUrl = $contentTypeSlug && $categoryPath && $slugVal
|
||||
? '/' . strtolower($contentTypeSlug) . '/' . strtolower($categoryPath) . '/' . $slugVal
|
||||
: null;
|
||||
@@ -60,7 +62,7 @@ class ArtworkListResource extends JsonResource
|
||||
'width' => $get('width'),
|
||||
'height' => $get('height'),
|
||||
],
|
||||
'thumbnail_url' => $this->when(! empty($get('file_path')), fn() => Storage::url($get('file_path'))),
|
||||
'thumbnail_url' => $this->when(! empty($hash) && ! empty($thumbExt), fn() => ThumbnailService::fromHash($hash, $thumbExt, 'md')),
|
||||
'author' => $this->whenLoaded('user', function () {
|
||||
return [
|
||||
'name' => $this->user->name ?? null,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Http\Resources\MissingValue;
|
||||
|
||||
class ArtworkResource extends JsonResource
|
||||
@@ -32,6 +31,21 @@ class ArtworkResource extends JsonResource
|
||||
}
|
||||
return null;
|
||||
};
|
||||
$hash = (string) ($get('hash') ?? '');
|
||||
$fileExt = (string) ($get('file_ext') ?? '');
|
||||
$filesBase = rtrim((string) config('cdn.files_url', ''), '/');
|
||||
|
||||
$buildOriginalUrl = static function (string $hashValue, string $extValue) use ($filesBase): ?string {
|
||||
$normalizedHash = strtolower((string) preg_replace('/[^a-f0-9]/', '', $hashValue));
|
||||
$normalizedExt = strtolower((string) preg_replace('/[^a-z0-9]/', '', $extValue));
|
||||
if ($normalizedHash === '' || $normalizedExt === '') return null;
|
||||
$h1 = substr($normalizedHash, 0, 2);
|
||||
$h2 = substr($normalizedHash, 2, 2);
|
||||
if ($h1 === '' || $h2 === '' || $filesBase === '') return null;
|
||||
|
||||
return sprintf('%s/originals/%s/%s/%s.%s', $filesBase, $h1, $h2, $normalizedHash, $normalizedExt);
|
||||
};
|
||||
|
||||
return [
|
||||
'slug' => $get('slug'),
|
||||
'title' => $get('title'),
|
||||
@@ -39,10 +53,10 @@ class ArtworkResource extends JsonResource
|
||||
'width' => $get('width'),
|
||||
'height' => $get('height'),
|
||||
|
||||
// File URLs: produce public URLs without exposing internal file_path
|
||||
// File URLs are derived from hash/ext (no DB path dependency)
|
||||
'file' => [
|
||||
'name' => $get('file_name') ?? null,
|
||||
'url' => $this->when(! empty($get('file_path')), fn() => Storage::url($get('file_path'))),
|
||||
'url' => $this->when(! empty($hash) && ! empty($fileExt), fn() => $buildOriginalUrl($hash, $fileExt)),
|
||||
'size' => $get('file_size') ?? null,
|
||||
'mime_type' => $get('mime_type') ?? null,
|
||||
],
|
||||
|
||||
441
app/Jobs/AutoTagArtworkJob.php
Normal file
441
app/Jobs/AutoTagArtworkJob.php
Normal file
@@ -0,0 +1,441 @@
|
||||
<?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\DB;
|
||||
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, [
|
||||
'url' => $imageUrl,
|
||||
'image_url' => $imageUrl,
|
||||
'limit' => 8,
|
||||
'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())]);
|
||||
|
||||
// Fallback: try uploading the local derivative file to the gateway's file upload
|
||||
// endpoint (`/analyze/all/file`) if the gateway cannot fetch the public URL.
|
||||
try {
|
||||
$variant = (string) config('vision.image_variant', 'md');
|
||||
$row = DB::table('artwork_files')
|
||||
->where('artwork_id', $this->artworkId)
|
||||
->where('variant', $variant)
|
||||
->first();
|
||||
|
||||
if ($row && ! empty($row->path)) {
|
||||
$storageRoot = rtrim((string) config('uploads.storage_root', ''), DIRECTORY_SEPARATOR);
|
||||
$absolute = $storageRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $row->path);
|
||||
if (is_file($absolute) && is_readable($absolute)) {
|
||||
$uploadUrl = rtrim($base, '/') . '/analyze/all/file';
|
||||
try {
|
||||
$attach = file_get_contents($absolute);
|
||||
if ($attach !== false) {
|
||||
$uploadResp = Http::attach('file', $attach, basename($absolute))
|
||||
->post($uploadUrl, ['limit' => 5]);
|
||||
|
||||
if ($uploadResp->ok()) {
|
||||
return $this->extractTagList($uploadResp->json());
|
||||
}
|
||||
Log::warning('CLIP upload fallback non-ok', ['ref' => $ref, 'status' => $uploadResp->status(), 'body' => $this->safeBody($uploadResp->body())]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('CLIP upload fallback failed', ['ref' => $ref, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('CLIP fallback check failed', ['ref' => $ref, 'error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
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, [
|
||||
'url' => $imageUrl,
|
||||
'image_url' => $imageUrl,
|
||||
'conf' => 0.25,
|
||||
'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\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use App\Services\ThumbnailService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* App\Models\Artwork
|
||||
@@ -74,13 +76,8 @@ class Artwork extends Model
|
||||
return null;
|
||||
}
|
||||
|
||||
$size = array_key_exists($size, self::THUMB_SIZES) ? $size : 'md';
|
||||
$h = $this->hash;
|
||||
$h1 = substr($h, 0, 2);
|
||||
$h2 = substr($h, 2, 2);
|
||||
$ext = $this->thumb_ext;
|
||||
|
||||
return "https://files.skinbase.org/{$size}/{$h1}/{$h2}/{$h}.{$ext}";
|
||||
$sizeKey = array_key_exists($size, self::THUMB_SIZES) ? $size : 'md';
|
||||
return ThumbnailService::fromHash($this->hash, $this->thumb_ext, $sizeKey);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,6 +96,19 @@ class Artwork extends Model
|
||||
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.
|
||||
*/
|
||||
@@ -132,6 +142,12 @@ class Artwork extends Model
|
||||
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
|
||||
{
|
||||
return $this->hasMany(ArtworkComment::class);
|
||||
@@ -142,6 +158,16 @@ class Artwork extends Model
|
||||
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
|
||||
{
|
||||
return $this->hasMany(ArtworkFeature::class, 'artwork_id');
|
||||
@@ -175,4 +201,24 @@ class Artwork extends Model
|
||||
{
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||
|
||||
use App\Models\Artwork;
|
||||
|
||||
class ContentType extends Model
|
||||
{
|
||||
@@ -19,6 +22,18 @@ class ContentType extends Model
|
||||
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
|
||||
{
|
||||
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',
|
||||
'email',
|
||||
'password',
|
||||
'role',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -53,4 +54,19 @@ class User extends Authenticatable
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
@@ -64,6 +83,14 @@ class ArtworkPolicy
|
||||
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).
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
|
||||
use App\Services\Upload\UploadDraftService;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -11,7 +19,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
// Bind UploadDraftService interface to implementation
|
||||
$this->app->singleton(UploadDraftServiceInterface::class, function ($app) {
|
||||
return new UploadDraftService($app->make('filesystem'));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,6 +30,93 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
$this->configureUploadRateLimiters();
|
||||
|
||||
// Provide toolbar counts and user info to layout views (port of legacy toolbar logic)
|
||||
View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) {
|
||||
$uploadCount = $favCount = $msgCount = $noticeCount = 0;
|
||||
$avatar = null;
|
||||
$displayName = null;
|
||||
$userId = null;
|
||||
|
||||
if (Auth::check()) {
|
||||
$userId = Auth::id();
|
||||
try {
|
||||
$uploadCount = DB::table('artworks')->where('user_id', $userId)->count();
|
||||
} catch (\Throwable $e) {
|
||||
$uploadCount = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// legacy table name fallback handled elsewhere; here we look for user_favorites or favourites
|
||||
$favCount = DB::table('user_favorites')->where('user_id', $userId)->count();
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
$favCount = DB::table('favourites')->where('user_id', $userId)->count();
|
||||
} catch (\Throwable $e) {
|
||||
$favCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$msgCount = DB::table('messages')->where('reciever_id', $userId)->whereNull('read_at')->count();
|
||||
} catch (\Throwable $e) {
|
||||
$msgCount = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$noticeCount = DB::table('notification')->where('user_id', $userId)->where('new', 1)->count();
|
||||
} catch (\Throwable $e) {
|
||||
$noticeCount = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$profile = DB::table('user_profiles')->where('user_id', $userId)->first();
|
||||
$avatar = $profile->avatar ?? null;
|
||||
} catch (\Throwable $e) {
|
||||
$avatar = null;
|
||||
}
|
||||
|
||||
$displayName = Auth::user()->name ?: (Auth::user()->username ?? '');
|
||||
}
|
||||
|
||||
$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' => '',
|
||||
'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
|
||||
{
|
||||
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'];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user