Files
patbond-doc/docs/database/patbond_postgresql.sql
2026-08-14 17:09:15 +08:00

1951 lines
89 KiB
PL/PgSQL

/*
Patbond PostgreSQL bootstrap schema
Version: 1.0
Target: PostgreSQL 16+
Execute on an empty database:
psql -v ON_ERROR_STOP=1 -d patbond -f docs/database/patbond_postgresql.sql
Design source: the current Flutter home, creation, pet-health, marketplace,
post-detail and profile pages. This is a one-time bootstrap file, not a
repeatable migration. After backend development starts, split it into owned
Flyway migrations and never edit an already released migration.
This file is the reviewed target schema. Backend delivery should still be
phased: identity/media -> pet health -> community -> creation -> marketplace.
A table appearing here does not require generating every repository in V1.
Logical ownership:
identity account, credentials, refresh sessions, profile preferences
media object-storage metadata; binary content is never stored here
pet_health pets, weights, vaccinations, reminders, health timeline
community posts, media, flat comments, likes, bookmarks, follows, topics
creation asynchronous AI generation jobs and configurable catalog
marketplace providers, offerings, reservable resources and bookings
platform regions, notifications and transactional outbox
Important projections which are intentionally NOT stored as source facts:
- "2小时前/刚刚": format timestamptz in the API/client.
- provider distance: calculate from coordinates; never store "1.2km".
- weather and pet advice: external weather API + Redis TTL cache.
- vaccine progress/next dose: aggregate vaccination fact rows.
- profile post/follower/like counts: aggregate relation tables/read model.
- age and monthly spend: derive from birth_date and completed expenses.
JSONB is restricted to variable AI parameters and outbox event payloads.
Core account, pet, community and booking facts remain relational.
UUID note: gen_random_uuid() is a safe database fallback. At production write
volume, applications should generate time-ordered UUIDv7 values to reduce
B-tree page churn while retaining globally unique public IDs.
Nearby search note: this portable bootstrap uses region + latitude/longitude
bounding-box indexes and a Haversine function. If nearby search becomes a core
large-scale workload (roughly >1,000 providers per region or radius paging),
migrate the coordinates to PostGIS geography(Point,4326) with a GiST index.
*/
BEGIN;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS citext;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE SCHEMA platform;
CREATE SCHEMA identity;
CREATE SCHEMA media;
CREATE SCHEMA pet_health;
CREATE SCHEMA community;
CREATE SCHEMA creation;
CREATE SCHEMA marketplace;
COMMENT ON SCHEMA platform IS 'Shared reference data, notifications and reliable outbox';
COMMENT ON SCHEMA identity IS 'Accounts, credentials, sessions and user preferences';
COMMENT ON SCHEMA media IS 'Metadata for objects stored in S3/OSS/MinIO or external fixtures';
COMMENT ON SCHEMA pet_health IS 'Pet profile, health facts, vaccines and reminders';
COMMENT ON SCHEMA community IS 'Posts and social interaction facts';
COMMENT ON SCHEMA creation IS 'Asynchronous AI image/video generation';
COMMENT ON SCHEMA marketplace IS 'Provider catalog, scheduling resources and bookings';
CREATE FUNCTION platform.set_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := clock_timestamp();
RETURN NEW;
END;
$$;
CREATE FUNCTION platform.distance_km(
latitude_a numeric,
longitude_a numeric,
latitude_b numeric,
longitude_b numeric
)
RETURNS double precision
LANGUAGE sql
IMMUTABLE
PARALLEL SAFE
RETURNS NULL ON NULL INPUT
AS $$
SELECT 6371.0088 * 2 * asin(
sqrt(
least(1.0, greatest(0.0,
power(sin(radians(($3 - $1)::double precision) / 2), 2) +
cos(radians($1::double precision)) *
cos(radians($3::double precision)) *
power(sin(radians(($4 - $2)::double precision) / 2), 2)
))
)
)
$$;
-- Flat selectable region rows avoid recursive province/city/district queries.
CREATE TABLE platform.regions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(32) NOT NULL UNIQUE,
country_code char(2) NOT NULL DEFAULT 'CN',
province_name varchar(64) NOT NULL,
city_name varchar(64) NOT NULL,
district_name varchar(64) NOT NULL,
latitude numeric(9,6) NOT NULL,
longitude numeric(9,6) NOT NULL,
timezone varchar(64) NOT NULL DEFAULT 'Asia/Shanghai',
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ck_regions_code CHECK (code = btrim(code) AND char_length(code) BETWEEN 2 AND 32),
CONSTRAINT ck_regions_latitude CHECK (latitude BETWEEN -90 AND 90),
CONSTRAINT ck_regions_longitude CHECK (longitude BETWEEN -180 AND 180)
);
CREATE INDEX ix_regions_city_district
ON platform.regions (city_name, district_name)
WHERE enabled;
CREATE TABLE identity.users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
username citext NOT NULL UNIQUE,
nickname varchar(32),
phone_e164 varchar(16),
email citext,
bio varchar(300),
avatar_asset_id uuid,
status varchar(16) NOT NULL DEFAULT 'active',
phone_verified_at timestamptz,
email_verified_at timestamptz,
last_login_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
version integer NOT NULL DEFAULT 0,
CONSTRAINT ck_users_username CHECK (
username::text = btrim(username::text)
AND char_length(username::text) BETWEEN 3 AND 32
),
CONSTRAINT ck_users_nickname CHECK (
nickname IS NULL OR (nickname = btrim(nickname) AND char_length(nickname) BETWEEN 1 AND 32)
),
CONSTRAINT ck_users_phone CHECK (
phone_e164 IS NULL OR phone_e164 ~ '^\+[1-9][0-9]{7,14}$'
),
CONSTRAINT ck_users_status CHECK (status IN ('active', 'locked', 'disabled', 'deleted')),
CONSTRAINT ck_users_version CHECK (version >= 0),
CONSTRAINT ck_users_deleted_state CHECK ((status = 'deleted') = (deleted_at IS NOT NULL))
);
CREATE UNIQUE INDEX uq_users_phone
ON identity.users (phone_e164)
WHERE phone_e164 IS NOT NULL AND status <> 'deleted';
CREATE UNIQUE INDEX uq_users_email
ON identity.users (email)
WHERE email IS NOT NULL AND status <> 'deleted';
CREATE INDEX ix_users_avatar_asset ON identity.users (avatar_asset_id);
CREATE TABLE identity.user_credentials (
user_id uuid PRIMARY KEY REFERENCES identity.users(id) ON DELETE RESTRICT,
password_hash varchar(255) NOT NULL,
hash_algorithm varchar(16) NOT NULL DEFAULT 'bcrypt',
password_changed_at timestamptz NOT NULL DEFAULT now(),
failed_login_count integer NOT NULL DEFAULT 0,
failure_window_started_at timestamptz,
last_failed_at timestamptz,
locked_until timestamptz,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ck_credentials_algorithm CHECK (hash_algorithm IN ('bcrypt', 'argon2id')),
CONSTRAINT ck_credentials_failed_count CHECK (failed_login_count >= 0),
CONSTRAINT ck_credentials_hash CHECK (char_length(password_hash) BETWEEN 20 AND 255)
);
CREATE TABLE identity.auth_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
token_family_id uuid NOT NULL,
refresh_token_hash bytea NOT NULL UNIQUE,
access_token_jti varchar(64),
device_id varchar(128),
device_name varchar(128),
user_agent varchar(512),
ip_address inet,
created_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
revoke_reason varchar(128),
rotated_at timestamptz,
replaced_by_session_id uuid REFERENCES identity.auth_sessions(id) ON DELETE SET NULL,
UNIQUE (replaced_by_session_id),
CONSTRAINT ck_sessions_refresh_hash CHECK (octet_length(refresh_token_hash) = 32),
CONSTRAINT ck_sessions_expiry CHECK (expires_at > created_at),
CONSTRAINT ck_sessions_revoked_at CHECK (revoked_at IS NULL OR revoked_at >= created_at),
CONSTRAINT ck_sessions_rotation CHECK (
replaced_by_session_id IS NULL
OR (replaced_by_session_id <> id AND revoked_at IS NOT NULL AND rotated_at IS NOT NULL)
)
);
CREATE INDEX ix_auth_sessions_user_created
ON identity.auth_sessions (user_id, created_at DESC);
CREATE INDEX ix_auth_sessions_active_expiry
ON identity.auth_sessions (expires_at)
WHERE revoked_at IS NULL;
CREATE INDEX ix_auth_sessions_family
ON identity.auth_sessions (token_family_id, created_at DESC);
CREATE UNIQUE INDEX uq_auth_sessions_access_jti
ON identity.auth_sessions (access_token_jti)
WHERE access_token_jti IS NOT NULL;
CREATE TABLE identity.user_addresses (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
region_id uuid NOT NULL REFERENCES platform.regions(id) ON DELETE RESTRICT,
label varchar(32) NOT NULL,
recipient_name varchar(64),
recipient_phone_e164 varchar(16),
address_line varchar(300) NOT NULL,
latitude numeric(9,6),
longitude numeric(9,6),
is_default boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT ck_user_addresses_label CHECK (label = btrim(label) AND char_length(label) BETWEEN 1 AND 32),
CONSTRAINT ck_user_addresses_line CHECK (address_line = btrim(address_line) AND char_length(address_line) BETWEEN 3 AND 300),
CONSTRAINT ck_user_addresses_phone CHECK (
recipient_phone_e164 IS NULL OR recipient_phone_e164 ~ '^\+[1-9][0-9]{7,14}$'
),
CONSTRAINT ck_user_addresses_coordinates CHECK (
(latitude IS NULL AND longitude IS NULL)
OR (
latitude IS NOT NULL AND longitude IS NOT NULL
AND latitude BETWEEN -90 AND 90
AND longitude BETWEEN -180 AND 180
)
)
);
CREATE INDEX ix_user_addresses_user ON identity.user_addresses (user_id);
CREATE INDEX ix_user_addresses_region ON identity.user_addresses (region_id);
CREATE UNIQUE INDEX uq_user_addresses_default
ON identity.user_addresses (user_id)
WHERE is_default AND deleted_at IS NULL;
CREATE TABLE identity.user_preferences (
user_id uuid PRIMARY KEY REFERENCES identity.users(id) ON DELETE CASCADE,
default_region_id uuid REFERENCES platform.regions(id) ON DELETE SET NULL,
locale varchar(16) NOT NULL DEFAULT 'zh-CN',
timezone varchar(64) NOT NULL DEFAULT 'Asia/Shanghai',
allow_precise_location boolean NOT NULL DEFAULT false,
marketing_notifications boolean NOT NULL DEFAULT false,
community_notifications boolean NOT NULL DEFAULT true,
health_reminders boolean NOT NULL DEFAULT true,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ck_user_preferences_locale CHECK (char_length(locale) BETWEEN 2 AND 16),
CONSTRAINT ck_user_preferences_timezone CHECK (char_length(timezone) BETWEEN 3 AND 64)
);
CREATE INDEX ix_user_preferences_region ON identity.user_preferences (default_region_id);
CREATE TABLE media.assets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id uuid REFERENCES identity.users(id) ON DELETE RESTRICT,
kind varchar(16) NOT NULL,
purpose varchar(32) NOT NULL,
storage_type varchar(16) NOT NULL DEFAULT 'object',
bucket varchar(63),
object_key varchar(1024),
external_url varchar(1024),
mime_type varchar(127) NOT NULL,
byte_size bigint,
sha256 bytea,
width_px integer,
height_px integer,
duration_ms bigint,
status varchar(16) NOT NULL DEFAULT 'uploading',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
ready_at timestamptz,
deleted_at timestamptz,
CONSTRAINT ck_media_kind CHECK (kind IN ('image', 'video', 'document')),
CONSTRAINT ck_media_storage_type CHECK (storage_type IN ('object', 'external')),
CONSTRAINT ck_media_location CHECK (
(
storage_type = 'object' AND bucket IS NOT NULL
AND object_key IS NOT NULL AND char_length(btrim(object_key)) > 0
AND external_url IS NULL
)
OR (
storage_type = 'external'
AND external_url IS NOT NULL AND char_length(btrim(external_url)) > 0
AND bucket IS NULL AND object_key IS NULL
)
),
CONSTRAINT ck_media_size CHECK (byte_size IS NULL OR byte_size >= 0),
CONSTRAINT ck_media_hash CHECK (sha256 IS NULL OR octet_length(sha256) = 32),
CONSTRAINT ck_media_dimensions CHECK (
(width_px IS NULL OR width_px > 0)
AND (height_px IS NULL OR height_px > 0)
AND (duration_ms IS NULL OR duration_ms >= 0)
),
CONSTRAINT ck_media_status CHECK (status IN ('uploading', 'ready', 'failed', 'deleted')),
CONSTRAINT ck_media_ready CHECK (status <> 'ready' OR ready_at IS NOT NULL),
CONSTRAINT ck_media_deleted CHECK (status <> 'deleted' OR deleted_at IS NOT NULL)
);
CREATE UNIQUE INDEX uq_media_object
ON media.assets (bucket, object_key)
WHERE storage_type = 'object';
CREATE UNIQUE INDEX uq_media_external_url
ON media.assets (external_url)
WHERE storage_type = 'external';
CREATE INDEX ix_media_owner_created ON media.assets (owner_user_id, created_at DESC);
CREATE INDEX ix_media_uploading_created
ON media.assets (created_at)
WHERE status = 'uploading';
ALTER TABLE identity.users
ADD CONSTRAINT fk_users_avatar_asset
FOREIGN KEY (avatar_asset_id) REFERENCES media.assets(id) ON DELETE SET NULL;
CREATE TABLE pet_health.breeds (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
species varchar(16) NOT NULL,
code varchar(64) NOT NULL UNIQUE,
display_name varchar(64) NOT NULL,
enabled boolean NOT NULL DEFAULT true,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (id, species),
CONSTRAINT ck_breeds_species CHECK (species IN ('dog', 'cat', 'other')),
CONSTRAINT ck_breeds_names CHECK (
code = btrim(code) AND display_name = btrim(display_name)
AND char_length(code) BETWEEN 2 AND 64
AND char_length(display_name) BETWEEN 1 AND 64
)
);
CREATE INDEX ix_breeds_species_order ON pet_health.breeds (species, sort_order, id) WHERE enabled;
CREATE TABLE pet_health.pets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name varchar(64) NOT NULL,
species varchar(16) NOT NULL,
breed_id uuid,
custom_breed_name varchar(64),
sex varchar(8) NOT NULL DEFAULT 'unknown',
birth_date date,
birth_date_estimated boolean NOT NULL DEFAULT false,
personality varchar(64),
avatar_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL,
microchip_no citext,
sterilized_on date,
status varchar(16) NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
version integer NOT NULL DEFAULT 0,
CONSTRAINT ck_pets_name CHECK (name = btrim(name) AND char_length(name) BETWEEN 1 AND 64),
CONSTRAINT ck_pets_species CHECK (species IN ('dog', 'cat', 'other')),
CONSTRAINT ck_pets_sex CHECK (sex IN ('male', 'female', 'unknown')),
FOREIGN KEY (breed_id, species)
REFERENCES pet_health.breeds(id, species) ON DELETE RESTRICT,
CONSTRAINT ck_pets_breed CHECK (
(breed_id IS NOT NULL AND custom_breed_name IS NULL)
OR (
breed_id IS NULL AND custom_breed_name IS NOT NULL
AND char_length(btrim(custom_breed_name)) BETWEEN 1 AND 64
)
),
CONSTRAINT ck_pets_birth_date CHECK (birth_date IS NULL OR birth_date >= DATE '1990-01-01'),
CONSTRAINT ck_pets_status CHECK (status IN ('active', 'lost', 'deceased', 'archived', 'deleted')),
CONSTRAINT ck_pets_version CHECK (version >= 0),
CONSTRAINT ck_pets_deleted CHECK ((status = 'deleted') = (deleted_at IS NOT NULL))
);
CREATE UNIQUE INDEX uq_pets_microchip
ON pet_health.pets (microchip_no)
WHERE microchip_no IS NOT NULL;
CREATE INDEX ix_pets_breed_species ON pet_health.pets (breed_id, species);
CREATE INDEX ix_pets_avatar ON pet_health.pets (avatar_asset_id);
CREATE INDEX ix_pets_active_updated ON pet_health.pets (updated_at DESC, id) WHERE status = 'active';
CREATE TABLE pet_health.pet_owners (
pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT,
role varchar(16) NOT NULL DEFAULT 'owner',
is_primary boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (pet_id, user_id),
CONSTRAINT ck_pet_owners_role CHECK (role IN ('owner', 'caregiver', 'viewer'))
);
CREATE INDEX ix_pet_owners_user ON pet_health.pet_owners (user_id, pet_id);
CREATE UNIQUE INDEX uq_pet_primary_owner
ON pet_health.pet_owners (pet_id)
WHERE is_primary;
CREATE TABLE pet_health.pet_weight_records (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE,
weight_kg numeric(6,2) NOT NULL,
measured_at timestamptz NOT NULL,
source varchar(16) NOT NULL DEFAULT 'manual',
note varchar(500),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ck_pet_weight CHECK (weight_kg > 0 AND weight_kg <= 500),
CONSTRAINT ck_pet_weight_source CHECK (source IN ('manual', 'clinic', 'device'))
);
CREATE INDEX ix_pet_weight_pet_measured
ON pet_health.pet_weight_records (pet_id, measured_at DESC, id DESC);
CREATE TABLE pet_health.vaccine_catalog (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(64) NOT NULL UNIQUE,
name varchar(128) NOT NULL,
species varchar(16) NOT NULL,
description varchar(500),
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ck_vaccine_species CHECK (species IN ('dog', 'cat', 'other')),
CONSTRAINT ck_vaccine_names CHECK (
code = btrim(code) AND name = btrim(name)
AND char_length(code) BETWEEN 2 AND 64
AND char_length(name) BETWEEN 1 AND 128
)
);
CREATE INDEX ix_vaccine_catalog_species ON pet_health.vaccine_catalog (species, name) WHERE enabled;
CREATE TABLE pet_health.pet_vaccinations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE,
vaccine_id uuid NOT NULL REFERENCES pet_health.vaccine_catalog(id) ON DELETE RESTRICT,
series_key varchar(64) NOT NULL,
dose_no smallint NOT NULL,
dose_label varchar(64),
status varchar(16) NOT NULL DEFAULT 'scheduled',
planned_on date,
administered_on date,
next_due_on date,
provider_id uuid,
provider_name_snapshot varchar(128),
manufacturer varchar(128),
batch_no varchar(64),
certificate_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL,
booking_id uuid,
notes varchar(1000),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
version integer NOT NULL DEFAULT 0,
CONSTRAINT ck_vaccination_series CHECK (series_key = btrim(series_key) AND char_length(series_key) BETWEEN 1 AND 64),
CONSTRAINT ck_vaccination_dose CHECK (dose_no > 0),
CONSTRAINT ck_vaccination_status CHECK (status IN ('scheduled', 'completed', 'cancelled')),
CONSTRAINT ck_vaccination_dates CHECK (
(status = 'completed' AND administered_on IS NOT NULL)
OR (status = 'scheduled' AND administered_on IS NULL AND planned_on IS NOT NULL)
OR (status = 'cancelled' AND administered_on IS NULL)
),
CONSTRAINT ck_vaccination_next_due CHECK (
next_due_on IS NULL OR administered_on IS NULL OR next_due_on >= administered_on
),
CONSTRAINT ck_vaccination_version CHECK (version >= 0)
);
CREATE UNIQUE INDEX uq_pet_vaccination_dose
ON pet_health.pet_vaccinations (pet_id, vaccine_id, series_key, dose_no)
WHERE status <> 'cancelled';
CREATE INDEX ix_vaccinations_pet ON pet_health.pet_vaccinations (pet_id);
CREATE INDEX ix_vaccinations_vaccine ON pet_health.pet_vaccinations (vaccine_id);
CREATE INDEX ix_vaccinations_certificate ON pet_health.pet_vaccinations (certificate_asset_id);
CREATE INDEX ix_vaccinations_provider ON pet_health.pet_vaccinations (provider_id);
CREATE INDEX ix_vaccinations_booking ON pet_health.pet_vaccinations (booking_id);
CREATE INDEX ix_vaccinations_pet_completed
ON pet_health.pet_vaccinations (pet_id, administered_on DESC, id DESC)
WHERE status = 'completed';
CREATE INDEX ix_vaccinations_due
ON pet_health.pet_vaccinations (planned_on, pet_id)
WHERE status = 'scheduled';
CREATE TABLE pet_health.health_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE,
event_type varchar(24) NOT NULL,
occurred_at timestamptz NOT NULL,
title varchar(160) NOT NULL,
notes text,
amount_cents bigint,
provider_id uuid,
provider_name_snapshot varchar(128),
booking_id uuid,
created_by_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
version integer NOT NULL DEFAULT 0,
CONSTRAINT ck_health_event_type CHECK (
event_type IN ('medical', 'feeding', 'deworming', 'grooming', 'measurement', 'note')
),
CONSTRAINT ck_health_event_title CHECK (title = btrim(title) AND char_length(title) BETWEEN 1 AND 160),
CONSTRAINT ck_health_event_amount CHECK (amount_cents IS NULL OR amount_cents >= 0),
CONSTRAINT ck_health_event_version CHECK (version >= 0)
);
CREATE INDEX ix_health_events_pet_time ON pet_health.health_events (pet_id, occurred_at DESC, id DESC);
CREATE INDEX ix_health_events_creator ON pet_health.health_events (created_by_user_id, created_at DESC);
CREATE INDEX ix_health_events_provider ON pet_health.health_events (provider_id);
CREATE INDEX ix_health_events_booking ON pet_health.health_events (booking_id);
CREATE TABLE pet_health.health_event_media (
health_event_id uuid NOT NULL REFERENCES pet_health.health_events(id) ON DELETE CASCADE,
position smallint NOT NULL,
asset_id uuid NOT NULL REFERENCES media.assets(id) ON DELETE RESTRICT,
document_type varchar(32),
issued_on date,
PRIMARY KEY (health_event_id, position),
UNIQUE (health_event_id, asset_id),
CONSTRAINT ck_health_event_media_position CHECK (position >= 0)
);
CREATE INDEX ix_health_event_media_asset ON pet_health.health_event_media (asset_id);
CREATE TABLE pet_health.care_reminders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE,
reminder_type varchar(24) NOT NULL,
title varchar(160) NOT NULL,
due_at timestamptz NOT NULL,
status varchar(16) NOT NULL DEFAULT 'pending',
completed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ck_care_reminder_type CHECK (
reminder_type IN ('deworming', 'checkup', 'medication', 'other')
),
CONSTRAINT ck_care_reminder_status CHECK (status IN ('pending', 'completed', 'dismissed')),
CONSTRAINT ck_care_reminder_completed CHECK ((status = 'completed') = (completed_at IS NOT NULL))
);
CREATE INDEX ix_care_reminders_pet ON pet_health.care_reminders (pet_id);
CREATE INDEX ix_care_reminders_due
ON pet_health.care_reminders (due_at, pet_id)
WHERE status = 'pending';
CREATE TABLE creation.generation_models (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(64) NOT NULL,
display_name varchar(128) NOT NULL,
provider_code varchar(64) NOT NULL,
provider_model_name varchar(128) NOT NULL,
media_kind varchar(16) NOT NULL,
enabled boolean NOT NULL DEFAULT true,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (code, media_kind),
UNIQUE (id, media_kind),
CONSTRAINT ck_generation_models_kind CHECK (media_kind IN ('image', 'video')),
CONSTRAINT ck_generation_models_names CHECK (
code = btrim(code) AND char_length(code) BETWEEN 2 AND 64
AND char_length(btrim(display_name)) BETWEEN 1 AND 128
)
);
CREATE INDEX ix_generation_models_kind_order
ON creation.generation_models (media_kind, sort_order, id)
WHERE enabled;
CREATE TABLE creation.generation_styles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(64) NOT NULL,
title varchar(64) NOT NULL,
subtitle varchar(128),
media_kind varchar(16) NOT NULL,
preview_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL,
enabled boolean NOT NULL DEFAULT true,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (code, media_kind),
UNIQUE (id, media_kind),
CONSTRAINT ck_generation_styles_kind CHECK (media_kind IN ('image', 'video')),
CONSTRAINT ck_generation_styles_names CHECK (
code = btrim(code) AND char_length(code) BETWEEN 2 AND 64
AND char_length(btrim(title)) BETWEEN 1 AND 64
)
);
CREATE INDEX ix_generation_styles_preview ON creation.generation_styles (preview_asset_id);
CREATE INDEX ix_generation_styles_kind_order
ON creation.generation_styles (media_kind, sort_order, id)
WHERE enabled;
CREATE TABLE creation.generation_jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT,
pet_id uuid REFERENCES pet_health.pets(id) ON DELETE SET NULL,
media_kind varchar(16) NOT NULL,
model_id uuid NOT NULL,
style_id uuid,
input_asset_id uuid NOT NULL REFERENCES media.assets(id) ON DELETE RESTRICT,
output_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL,
prompt varchar(10000),
negative_prompt varchar(3000),
width_px integer,
height_px integer,
duration_ms bigint,
upscale boolean NOT NULL DEFAULT false,
parameters jsonb NOT NULL DEFAULT '{}'::jsonb,
provider_code_snapshot varchar(64) NOT NULL,
provider_model_snapshot varchar(128) NOT NULL,
model_version_snapshot varchar(64) NOT NULL,
style_code_snapshot varchar(64),
status varchar(16) NOT NULL DEFAULT 'queued',
progress smallint NOT NULL DEFAULT 0,
priority smallint NOT NULL DEFAULT 0,
attempt_count smallint NOT NULL DEFAULT 0,
max_attempts smallint NOT NULL DEFAULT 3,
provider_request_id varchar(128),
error_code varchar(64),
error_message varchar(1000),
idempotency_key varchar(128) NOT NULL,
request_hash bytea NOT NULL,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
lease_owner varchar(128),
lease_expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
started_at timestamptz,
completed_at timestamptz,
version integer NOT NULL DEFAULT 0,
UNIQUE (user_id, idempotency_key),
FOREIGN KEY (model_id, media_kind)
REFERENCES creation.generation_models(id, media_kind) ON DELETE RESTRICT,
FOREIGN KEY (style_id, media_kind)
REFERENCES creation.generation_styles(id, media_kind) ON DELETE RESTRICT,
CONSTRAINT ck_generation_jobs_kind CHECK (media_kind IN ('image', 'video')),
CONSTRAINT ck_generation_jobs_dimensions CHECK (
(width_px IS NULL OR width_px BETWEEN 64 AND 8192)
AND (height_px IS NULL OR height_px BETWEEN 64 AND 8192)
AND (duration_ms IS NULL OR duration_ms > 0)
),
CONSTRAINT ck_generation_jobs_parameters CHECK (jsonb_typeof(parameters) = 'object'),
CONSTRAINT ck_generation_jobs_idempotency CHECK (
idempotency_key = btrim(idempotency_key)
AND char_length(idempotency_key) BETWEEN 1 AND 128
AND octet_length(request_hash) = 32
),
CONSTRAINT ck_generation_jobs_status CHECK (
status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')
),
CONSTRAINT ck_generation_jobs_progress CHECK (progress BETWEEN 0 AND 100),
CONSTRAINT ck_generation_jobs_attempts CHECK (
attempt_count >= 0 AND max_attempts > 0 AND attempt_count <= max_attempts
),
CONSTRAINT ck_generation_jobs_state CHECK (
(
status = 'queued' AND progress = 0 AND started_at IS NULL AND completed_at IS NULL
AND output_asset_id IS NULL AND error_code IS NULL
AND lease_owner IS NULL AND lease_expires_at IS NULL
)
OR (
status = 'running' AND started_at IS NOT NULL AND completed_at IS NULL
AND output_asset_id IS NULL AND error_code IS NULL
AND lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL
)
OR (
status = 'succeeded' AND progress = 100 AND started_at IS NOT NULL
AND completed_at IS NOT NULL AND output_asset_id IS NOT NULL AND error_code IS NULL
AND lease_owner IS NULL AND lease_expires_at IS NULL
)
OR (
status = 'failed' AND completed_at IS NOT NULL AND error_code IS NOT NULL
AND output_asset_id IS NULL AND lease_owner IS NULL AND lease_expires_at IS NULL
)
OR (
status = 'cancelled' AND completed_at IS NOT NULL
AND lease_owner IS NULL AND lease_expires_at IS NULL
)
),
CONSTRAINT ck_generation_jobs_lease CHECK (
(lease_owner IS NULL) = (lease_expires_at IS NULL)
AND (lease_expires_at IS NULL OR started_at IS NULL OR lease_expires_at > started_at)
),
CONSTRAINT ck_generation_jobs_version CHECK (version >= 0)
);
CREATE UNIQUE INDEX uq_generation_jobs_provider_request
ON creation.generation_jobs (provider_code_snapshot, provider_request_id)
WHERE provider_request_id IS NOT NULL;
CREATE INDEX ix_generation_jobs_user_created
ON creation.generation_jobs (user_id, created_at DESC, id DESC);
CREATE INDEX ix_generation_jobs_pet ON creation.generation_jobs (pet_id);
CREATE INDEX ix_generation_jobs_model ON creation.generation_jobs (model_id);
CREATE INDEX ix_generation_jobs_style ON creation.generation_jobs (style_id);
CREATE INDEX ix_generation_jobs_model_kind ON creation.generation_jobs (model_id, media_kind);
CREATE INDEX ix_generation_jobs_style_kind ON creation.generation_jobs (style_id, media_kind);
CREATE INDEX ix_generation_jobs_input ON creation.generation_jobs (input_asset_id);
CREATE INDEX ix_generation_jobs_output ON creation.generation_jobs (output_asset_id);
CREATE INDEX ix_generation_jobs_queue
ON creation.generation_jobs (priority DESC, next_attempt_at, created_at, id)
WHERE status = 'queued';
CREATE INDEX ix_generation_jobs_running
ON creation.generation_jobs (lease_expires_at, id)
WHERE status = 'running';
CREATE TABLE community.posts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
author_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT,
pet_id uuid REFERENCES pet_health.pets(id) ON DELETE SET NULL,
generation_job_id uuid REFERENCES creation.generation_jobs(id) ON DELETE SET NULL,
category varchar(24) NOT NULL DEFAULT 'general',
title varchar(120),
content text NOT NULL,
status varchar(16) NOT NULL DEFAULT 'draft',
visibility varchar(16) NOT NULL DEFAULT 'public',
region_id uuid REFERENCES platform.regions(id) ON DELETE SET NULL,
location_text_snapshot varchar(128),
like_count bigint NOT NULL DEFAULT 0,
comment_count bigint NOT NULL DEFAULT 0,
bookmark_count bigint NOT NULL DEFAULT 0,
idempotency_key varchar(128),
request_hash bytea,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz,
deleted_at timestamptz,
version integer NOT NULL DEFAULT 0,
CONSTRAINT uq_posts_author_idempotency UNIQUE (author_user_id, idempotency_key),
CONSTRAINT ck_posts_category CHECK (category IN ('general', 'help', 'ai_creation')),
CONSTRAINT ck_posts_title CHECK (title IS NULL OR char_length(btrim(title)) BETWEEN 1 AND 120),
CONSTRAINT ck_posts_content CHECK (char_length(btrim(content)) BETWEEN 1 AND 10000),
CONSTRAINT ck_posts_status CHECK (status IN ('draft', 'published', 'hidden', 'archived')),
CONSTRAINT ck_posts_visibility CHECK (visibility IN ('public', 'followers', 'private')),
CONSTRAINT ck_posts_publish_state CHECK (
(status = 'published' AND published_at IS NOT NULL AND deleted_at IS NULL)
OR status <> 'published'
),
CONSTRAINT ck_posts_counts CHECK (like_count >= 0 AND comment_count >= 0 AND bookmark_count >= 0),
CONSTRAINT ck_posts_idempotency CHECK (
(idempotency_key IS NULL AND request_hash IS NULL)
OR (
idempotency_key = btrim(idempotency_key)
AND char_length(idempotency_key) BETWEEN 1 AND 128
AND octet_length(request_hash) = 32
)
),
CONSTRAINT ck_posts_version CHECK (version >= 0)
);
CREATE INDEX ix_posts_feed
ON community.posts (published_at DESC, id DESC)
WHERE status = 'published' AND visibility = 'public';
CREATE INDEX ix_posts_author_created
ON community.posts (author_user_id, created_at DESC, id DESC);
CREATE INDEX ix_posts_pet_created ON community.posts (pet_id, created_at DESC);
CREATE INDEX ix_posts_generation_job ON community.posts (generation_job_id);
CREATE INDEX ix_posts_region_feed
ON community.posts (region_id, published_at DESC, id DESC)
WHERE status = 'published' AND visibility = 'public';
CREATE INDEX ix_posts_region ON community.posts (region_id);
CREATE INDEX ix_posts_content_trgm
ON community.posts USING gin (content gin_trgm_ops)
WHERE status = 'published';
CREATE TABLE community.post_media (
post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE,
position smallint NOT NULL,
asset_id uuid NOT NULL REFERENCES media.assets(id) ON DELETE RESTRICT,
is_cover boolean NOT NULL DEFAULT false,
caption varchar(300),
PRIMARY KEY (post_id, position),
UNIQUE (post_id, asset_id),
CONSTRAINT ck_post_media_position CHECK (position >= 0)
);
CREATE INDEX ix_post_media_asset ON community.post_media (asset_id);
CREATE UNIQUE INDEX uq_post_media_cover
ON community.post_media (post_id)
WHERE is_cover;
-- Comments are deliberately one flat level. reply_to_user_id supports @ replies
-- without parent_comment_id, recursive queries, or unbounded comment nesting.
CREATE TABLE community.comments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE,
author_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT,
reply_to_user_id uuid REFERENCES identity.users(id) ON DELETE SET NULL,
content varchar(2000) NOT NULL,
status varchar(16) NOT NULL DEFAULT 'visible',
client_request_id varchar(128),
request_hash bytea,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
UNIQUE (author_user_id, client_request_id),
CONSTRAINT ck_comments_content CHECK (char_length(btrim(content)) BETWEEN 1 AND 2000),
CONSTRAINT ck_comments_status CHECK (status IN ('visible', 'hidden', 'deleted')),
CONSTRAINT ck_comments_idempotency CHECK (
(client_request_id IS NULL AND request_hash IS NULL)
OR (
client_request_id = btrim(client_request_id)
AND char_length(client_request_id) BETWEEN 1 AND 128
AND octet_length(request_hash) = 32
)
),
CONSTRAINT ck_comments_deleted CHECK ((status = 'deleted') = (deleted_at IS NOT NULL))
);
CREATE INDEX ix_comments_post_created
ON community.comments (post_id, created_at DESC, id DESC);
CREATE INDEX ix_comments_author_created
ON community.comments (author_user_id, created_at DESC, id DESC);
CREATE INDEX ix_comments_reply_user
ON community.comments (reply_to_user_id, created_at DESC);
CREATE TABLE community.post_likes (
post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (post_id, user_id)
);
CREATE INDEX ix_post_likes_user_created
ON community.post_likes (user_id, created_at DESC, post_id);
CREATE TABLE community.post_bookmarks (
post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (post_id, user_id)
);
CREATE INDEX ix_post_bookmarks_user_created
ON community.post_bookmarks (user_id, created_at DESC, post_id);
CREATE TABLE community.user_follows (
follower_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
followee_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (follower_user_id, followee_user_id),
CONSTRAINT ck_user_follows_self CHECK (follower_user_id <> followee_user_id)
);
CREATE INDEX ix_user_follows_followee
ON community.user_follows (followee_user_id, created_at DESC, follower_user_id);
CREATE TABLE community.topics (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name citext NOT NULL UNIQUE,
description varchar(300),
status varchar(16) NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ck_topics_name CHECK (name::text = btrim(name::text) AND char_length(name::text) BETWEEN 1 AND 32),
CONSTRAINT ck_topics_status CHECK (status IN ('active', 'hidden'))
);
CREATE TABLE community.post_topics (
post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE,
topic_id uuid NOT NULL REFERENCES community.topics(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, topic_id)
);
CREATE INDEX ix_post_topics_topic ON community.post_topics (topic_id, post_id);
CREATE TABLE marketplace.providers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id uuid REFERENCES identity.users(id) ON DELETE SET NULL,
kind varchar(16) NOT NULL,
slug varchar(128) NOT NULL UNIQUE,
name varchar(128) NOT NULL,
description text NOT NULL,
phone_e164 varchar(16),
region_id uuid NOT NULL REFERENCES platform.regions(id) ON DELETE RESTRICT,
address_line varchar(300) NOT NULL,
latitude numeric(9,6) NOT NULL,
longitude numeric(9,6) NOT NULL,
cover_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL,
verification_status varchar(16) NOT NULL DEFAULT 'unverified',
verified_at timestamptz,
is_24h boolean NOT NULL DEFAULT false,
rating_avg numeric(3,2) NOT NULL DEFAULT 0,
rating_count integer NOT NULL DEFAULT 0,
status varchar(16) NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
version integer NOT NULL DEFAULT 0,
CONSTRAINT ck_providers_kind CHECK (kind IN ('hospital', 'grooming', 'personal')),
CONSTRAINT ck_providers_slug CHECK (slug = lower(btrim(slug)) AND slug ~ '^[a-z0-9][a-z0-9-]{1,127}$'),
CONSTRAINT ck_providers_name CHECK (name = btrim(name) AND char_length(name) BETWEEN 1 AND 128),
CONSTRAINT ck_providers_description CHECK (char_length(btrim(description)) BETWEEN 1 AND 5000),
CONSTRAINT ck_providers_phone CHECK (phone_e164 IS NULL OR phone_e164 ~ '^\+[1-9][0-9]{7,14}$'),
CONSTRAINT ck_providers_coordinates CHECK (latitude BETWEEN -90 AND 90 AND longitude BETWEEN -180 AND 180),
CONSTRAINT ck_providers_verification CHECK (
verification_status IN ('unverified', 'pending', 'verified', 'rejected', 'expired')
AND (verification_status <> 'verified' OR verified_at IS NOT NULL)
),
CONSTRAINT ck_providers_rating CHECK (rating_avg BETWEEN 0 AND 5 AND rating_count >= 0),
CONSTRAINT ck_providers_status CHECK (status IN ('pending', 'active', 'suspended', 'closed')),
CONSTRAINT ck_providers_version CHECK (version >= 0)
);
CREATE INDEX ix_providers_owner ON marketplace.providers (owner_user_id);
CREATE INDEX ix_providers_region_location
ON marketplace.providers (region_id, latitude, longitude)
WHERE status = 'active';
CREATE INDEX ix_providers_region ON marketplace.providers (region_id);
CREATE INDEX ix_providers_kind_rating
ON marketplace.providers (kind, rating_avg DESC, id)
WHERE status = 'active';
CREATE INDEX ix_providers_cover ON marketplace.providers (cover_asset_id);
CREATE INDEX ix_providers_name_trgm
ON marketplace.providers USING gin (name gin_trgm_ops)
WHERE status = 'active';
CREATE INDEX ix_providers_description_trgm
ON marketplace.providers USING gin (description gin_trgm_ops)
WHERE status = 'active';
CREATE TABLE marketplace.provider_tags (
provider_id uuid NOT NULL REFERENCES marketplace.providers(id) ON DELETE CASCADE,
tag citext NOT NULL,
PRIMARY KEY (provider_id, tag),
CONSTRAINT ck_provider_tags_value CHECK (
tag::text = btrim(tag::text) AND char_length(tag::text) BETWEEN 1 AND 32
)
);
CREATE INDEX ix_provider_tags_search
ON marketplace.provider_tags USING gin ((tag::text) gin_trgm_ops);
CREATE TABLE marketplace.service_offerings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id uuid NOT NULL REFERENCES marketplace.providers(id) ON DELETE RESTRICT,
category varchar(24) NOT NULL,
name varchar(128) NOT NULL,
description text,
base_price_cents bigint NOT NULL,
currency char(3) NOT NULL DEFAULT 'CNY',
pricing_unit varchar(24) NOT NULL DEFAULT 'service',
duration_minutes smallint,
status varchar(16) NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
version integer NOT NULL DEFAULT 0,
UNIQUE (id, provider_id),
UNIQUE (provider_id, name),
CONSTRAINT ck_service_offerings_category CHECK (
category IN ('feeding', 'walking', 'bathing', 'grooming', 'boarding', 'transport', 'hospital', 'adoption', 'other')
),
CONSTRAINT ck_service_offerings_name CHECK (name = btrim(name) AND char_length(name) BETWEEN 1 AND 128),
CONSTRAINT ck_service_offerings_price CHECK (base_price_cents >= 0),
CONSTRAINT ck_service_offerings_currency CHECK (currency ~ '^[A-Z]{3}$'),
CONSTRAINT ck_service_offerings_duration CHECK (duration_minutes IS NULL OR duration_minutes BETWEEN 5 AND 1440),
CONSTRAINT ck_service_offerings_status CHECK (status IN ('active', 'inactive')),
CONSTRAINT ck_service_offerings_version CHECK (version >= 0)
);
CREATE INDEX ix_service_offerings_provider
ON marketplace.service_offerings (provider_id, category, id);
CREATE INDEX ix_service_offerings_category_price
ON marketplace.service_offerings (category, base_price_cents, id)
WHERE status = 'active';
CREATE INDEX ix_service_offerings_name_trgm
ON marketplace.service_offerings USING gin (name gin_trgm_ops)
WHERE status = 'active';
CREATE TABLE marketplace.service_resources (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id uuid NOT NULL REFERENCES marketplace.providers(id) ON DELETE RESTRICT,
name varchar(128) NOT NULL,
resource_type varchar(24) NOT NULL,
status varchar(16) NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (id, provider_id),
UNIQUE (provider_id, name),
CONSTRAINT ck_service_resources_name CHECK (name = btrim(name) AND char_length(name) BETWEEN 1 AND 128),
CONSTRAINT ck_service_resources_type CHECK (resource_type IN ('person', 'room', 'vehicle', 'capacity_unit')),
CONSTRAINT ck_service_resources_status CHECK (status IN ('active', 'inactive'))
);
CREATE INDEX ix_service_resources_provider ON marketplace.service_resources (provider_id, id);
CREATE TABLE marketplace.offering_resources (
provider_id uuid NOT NULL,
offering_id uuid NOT NULL,
resource_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (offering_id, resource_id),
UNIQUE (offering_id, resource_id, provider_id),
FOREIGN KEY (offering_id, provider_id)
REFERENCES marketplace.service_offerings(id, provider_id) ON DELETE CASCADE,
FOREIGN KEY (resource_id, provider_id)
REFERENCES marketplace.service_resources(id, provider_id) ON DELETE CASCADE
);
CREATE INDEX ix_offering_resources_resource ON marketplace.offering_resources (resource_id, offering_id);
CREATE INDEX ix_offering_resources_provider ON marketplace.offering_resources (provider_id, offering_id);
CREATE INDEX ix_offering_resources_offering_provider
ON marketplace.offering_resources (offering_id, provider_id);
CREATE INDEX ix_offering_resources_resource_provider
ON marketplace.offering_resources (resource_id, provider_id);
CREATE TABLE marketplace.bookings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
customer_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT,
pet_id uuid REFERENCES pet_health.pets(id) ON DELETE RESTRICT,
provider_id uuid NOT NULL REFERENCES marketplace.providers(id) ON DELETE RESTRICT,
offering_id uuid NOT NULL,
resource_id uuid,
scheduled_start_at timestamptz,
scheduled_end_at timestamptz,
service_window tstzrange GENERATED ALWAYS AS (
CASE
WHEN scheduled_start_at IS NULL OR scheduled_end_at IS NULL THEN NULL
ELSE tstzrange(scheduled_start_at, scheduled_end_at, '[)')
END
) STORED,
status varchar(16) NOT NULL DEFAULT 'pending',
hold_expires_at timestamptz,
provider_name_snapshot varchar(128) NOT NULL,
service_name_snapshot varchar(128) NOT NULL,
pet_name_snapshot varchar(64),
address_snapshot varchar(300) NOT NULL,
base_amount_cents bigint NOT NULL,
discount_amount_cents bigint NOT NULL DEFAULT 0,
payable_amount_cents bigint NOT NULL,
currency char(3) NOT NULL DEFAULT 'CNY',
customer_note varchar(500),
idempotency_key varchar(128) NOT NULL,
request_hash bytea NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
cancelled_at timestamptz,
completed_at timestamptz,
version integer NOT NULL DEFAULT 0,
UNIQUE (customer_user_id, idempotency_key),
FOREIGN KEY (offering_id, provider_id)
REFERENCES marketplace.service_offerings(id, provider_id) ON DELETE RESTRICT,
FOREIGN KEY (offering_id, resource_id, provider_id)
REFERENCES marketplace.offering_resources(offering_id, resource_id, provider_id) ON DELETE RESTRICT,
CONSTRAINT ck_bookings_status CHECK (
status IN ('pending', 'held', 'confirmed', 'in_service', 'completed', 'cancelled', 'rejected', 'expired')
),
CONSTRAINT ck_bookings_schedule CHECK (
(scheduled_start_at IS NULL AND scheduled_end_at IS NULL)
OR (scheduled_start_at IS NOT NULL AND scheduled_end_at IS NOT NULL AND scheduled_start_at < scheduled_end_at)
),
CONSTRAINT ck_bookings_active_schedule CHECK (
status NOT IN ('held', 'confirmed', 'in_service')
OR (resource_id IS NOT NULL AND scheduled_start_at IS NOT NULL AND scheduled_end_at IS NOT NULL)
),
CONSTRAINT ck_bookings_amount CHECK (
base_amount_cents >= 0
AND discount_amount_cents >= 0
AND discount_amount_cents <= base_amount_cents
AND payable_amount_cents = base_amount_cents - discount_amount_cents
),
CONSTRAINT ck_bookings_currency CHECK (currency ~ '^[A-Z]{3}$'),
CONSTRAINT ck_bookings_idempotency CHECK (
idempotency_key = btrim(idempotency_key)
AND char_length(idempotency_key) BETWEEN 1 AND 128
AND octet_length(request_hash) = 32
),
CONSTRAINT ck_bookings_completion CHECK ((status = 'completed') = (completed_at IS NOT NULL)),
CONSTRAINT ck_bookings_cancellation CHECK ((status = 'cancelled') = (cancelled_at IS NOT NULL)),
CONSTRAINT ck_bookings_hold_expiry CHECK (
(status = 'held') = (hold_expires_at IS NOT NULL)
AND (hold_expires_at IS NULL OR hold_expires_at > created_at)
),
CONSTRAINT ck_bookings_version CHECK (version >= 0)
);
ALTER TABLE marketplace.bookings
ADD CONSTRAINT ex_bookings_resource_overlap
EXCLUDE USING gist (
resource_id WITH =,
service_window WITH &&
)
WHERE (status IN ('held', 'confirmed', 'in_service'))
DEFERRABLE INITIALLY IMMEDIATE;
CREATE INDEX ix_bookings_customer_created
ON marketplace.bookings (customer_user_id, created_at DESC, id DESC);
CREATE INDEX ix_bookings_pet ON marketplace.bookings (pet_id);
CREATE INDEX ix_bookings_provider_schedule
ON marketplace.bookings (provider_id, scheduled_start_at, id);
CREATE INDEX ix_bookings_offering_schedule
ON marketplace.bookings (offering_id, scheduled_start_at, id);
CREATE INDEX ix_bookings_resource ON marketplace.bookings (resource_id);
CREATE INDEX ix_bookings_offering_provider
ON marketplace.bookings (offering_id, provider_id);
CREATE INDEX ix_bookings_offering_resource_provider
ON marketplace.bookings (offering_id, resource_id, provider_id);
CREATE INDEX ix_bookings_held_expiry
ON marketplace.bookings (hold_expires_at, id)
WHERE status = 'held';
CREATE TABLE marketplace.booking_status_history (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
booking_id uuid NOT NULL REFERENCES marketplace.bookings(id) ON DELETE CASCADE,
from_status varchar(16),
to_status varchar(16) NOT NULL,
actor_user_id uuid REFERENCES identity.users(id) ON DELETE SET NULL,
reason varchar(500),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ck_booking_history_status CHECK (
(from_status IS NULL OR from_status IN ('pending', 'held', 'confirmed', 'in_service', 'completed', 'cancelled', 'rejected', 'expired'))
AND to_status IN ('pending', 'held', 'confirmed', 'in_service', 'completed', 'cancelled', 'rejected', 'expired')
AND from_status IS DISTINCT FROM to_status
)
);
CREATE INDEX ix_booking_history_booking
ON marketplace.booking_status_history (booking_id, created_at, id);
CREATE INDEX ix_booking_history_actor ON marketplace.booking_status_history (actor_user_id);
CREATE FUNCTION marketplace.validate_booking_transition()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.status IS NOT DISTINCT FROM OLD.status THEN
RETURN NEW;
END IF;
IF NOT (
(OLD.status = 'pending' AND NEW.status IN ('held', 'confirmed', 'rejected', 'cancelled', 'expired'))
OR (OLD.status = 'held' AND NEW.status IN ('confirmed', 'cancelled', 'expired'))
OR (OLD.status = 'confirmed' AND NEW.status IN ('in_service', 'cancelled'))
OR (OLD.status = 'in_service' AND NEW.status IN ('completed', 'cancelled'))
) THEN
RAISE EXCEPTION 'illegal booking status transition: % -> %', OLD.status, NEW.status
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_bookings_validate_transition
BEFORE UPDATE OF status ON marketplace.bookings
FOR EACH ROW EXECUTE FUNCTION marketplace.validate_booking_transition();
ALTER TABLE pet_health.pet_vaccinations
ADD CONSTRAINT fk_vaccinations_provider
FOREIGN KEY (provider_id) REFERENCES marketplace.providers(id) ON DELETE SET NULL,
ADD CONSTRAINT fk_vaccinations_booking
FOREIGN KEY (booking_id) REFERENCES marketplace.bookings(id) ON DELETE SET NULL;
ALTER TABLE pet_health.health_events
ADD CONSTRAINT fk_health_events_provider
FOREIGN KEY (provider_id) REFERENCES marketplace.providers(id) ON DELETE SET NULL,
ADD CONSTRAINT fk_health_events_booking
FOREIGN KEY (booking_id) REFERENCES marketplace.bookings(id) ON DELETE SET NULL;
CREATE TABLE platform.notifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
notification_type varchar(64) NOT NULL,
title varchar(160) NOT NULL,
body varchar(1000) NOT NULL,
target_type varchar(64),
target_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
read_at timestamptz,
archived_at timestamptz,
CONSTRAINT ck_notifications_type CHECK (notification_type = btrim(notification_type) AND char_length(notification_type) BETWEEN 2 AND 64),
CONSTRAINT ck_notifications_title CHECK (char_length(btrim(title)) BETWEEN 1 AND 160),
CONSTRAINT ck_notifications_body CHECK (char_length(btrim(body)) BETWEEN 1 AND 1000)
);
CREATE INDEX ix_notifications_user_created
ON platform.notifications (user_id, created_at DESC, id DESC);
CREATE INDEX ix_notifications_user_unread
ON platform.notifications (user_id, created_at DESC, id DESC)
WHERE read_at IS NULL AND archived_at IS NULL;
CREATE TABLE platform.outbox_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_type varchar(64) NOT NULL,
aggregate_id uuid NOT NULL,
aggregate_version bigint NOT NULL,
event_type varchar(128) NOT NULL,
schema_version smallint NOT NULL DEFAULT 1,
payload jsonb NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now(),
available_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz,
attempt_count integer NOT NULL DEFAULT 0,
max_attempts integer NOT NULL DEFAULT 12,
last_error varchar(1000),
locked_at timestamptz,
locked_by varchar(128),
dead_lettered_at timestamptz,
UNIQUE (aggregate_type, aggregate_id, aggregate_version, event_type),
CONSTRAINT ck_outbox_names CHECK (
char_length(btrim(aggregate_type)) BETWEEN 2 AND 64
AND char_length(btrim(event_type)) BETWEEN 2 AND 128
),
CONSTRAINT ck_outbox_payload CHECK (jsonb_typeof(payload) = 'object'),
CONSTRAINT ck_outbox_versions CHECK (aggregate_version >= 0 AND schema_version > 0),
CONSTRAINT ck_outbox_attempts CHECK (attempt_count >= 0 AND max_attempts > 0),
CONSTRAINT ck_outbox_lock CHECK ((locked_at IS NULL) = (locked_by IS NULL)),
CONSTRAINT ck_outbox_terminal CHECK (published_at IS NULL OR dead_lettered_at IS NULL)
);
CREATE INDEX ix_outbox_unpublished
ON platform.outbox_events (available_at, occurred_at, id)
WHERE published_at IS NULL AND dead_lettered_at IS NULL;
CREATE INDEX ix_outbox_aggregate
ON platform.outbox_events (aggregate_type, aggregate_id, occurred_at);
-- Automatic updated_at maintenance. Business version increments remain explicit
-- so optimistic locking stays visible in repository update statements.
CREATE TRIGGER trg_users_updated_at BEFORE UPDATE ON identity.users
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_credentials_updated_at BEFORE UPDATE ON identity.user_credentials
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_addresses_updated_at BEFORE UPDATE ON identity.user_addresses
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_preferences_updated_at BEFORE UPDATE ON identity.user_preferences
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_media_updated_at BEFORE UPDATE ON media.assets
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_pets_updated_at BEFORE UPDATE ON pet_health.pets
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_vaccinations_updated_at BEFORE UPDATE ON pet_health.pet_vaccinations
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_health_events_updated_at BEFORE UPDATE ON pet_health.health_events
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_reminders_updated_at BEFORE UPDATE ON pet_health.care_reminders
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_generation_models_updated_at BEFORE UPDATE ON creation.generation_models
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_generation_styles_updated_at BEFORE UPDATE ON creation.generation_styles
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_generation_jobs_updated_at BEFORE UPDATE ON creation.generation_jobs
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_posts_updated_at BEFORE UPDATE ON community.posts
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_comments_updated_at BEFORE UPDATE ON community.comments
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_providers_updated_at BEFORE UPDATE ON marketplace.providers
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_offerings_updated_at BEFORE UPDATE ON marketplace.service_offerings
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_resources_updated_at BEFORE UPDATE ON marketplace.service_resources
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_bookings_updated_at BEFORE UPDATE ON marketplace.bookings
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
/* -------------------------------------------------------------------------
Development fixture data
The following rows mirror the Flutter demo enough to exercise all page data
paths. Remove this section from the production Flyway baseline. All fixture
accounts use the password: Patbond@123
--------------------------------------------------------------------------- */
INSERT INTO platform.regions
(id, code, province_name, city_name, district_name, latitude, longitude)
VALUES
('10000000-0000-7000-8000-000000000001', 'CN-BJ-CY', '北京市', '北京', '朝阳区', 39.921900, 116.443550),
('10000000-0000-7000-8000-000000000002', 'CN-SH-PD', '上海市', '上海', '浦东新区', 31.221140, 121.544090),
('10000000-0000-7000-8000-000000000003', 'CN-GD-SZ-NS', '广东省', '深圳', '南山区', 22.533320, 113.930410),
('10000000-0000-7000-8000-000000000004', 'CN-SC-CD-GX', '四川省', '成都', '高新区', 30.544730, 104.069760),
('10000000-0000-7000-8000-000000000005', 'CN-ZJ-HZ-XH', '浙江省', '杭州', '西湖区', 30.259610, 120.130260),
('10000000-0000-7000-8000-000000000006', 'CN-HLJ-HEB-DL', '黑龙江省', '哈尔滨', '道里区', 45.755020, 126.616990);
INSERT INTO identity.users
(id, username, nickname, phone_e164, bio, status, created_at)
VALUES
('11000000-0000-7000-8000-000000000001', 'demo_user', '豆豆的妈妈', '+8613800000001', 'Patbond 社区创作达人', 'active', now() - interval '180 days'),
('11000000-0000-7000-8000-000000000002', 'corgi_lover', 'CorgiLover99', '+8613800000002', NULL, 'active', now() - interval '120 days'),
('11000000-0000-7000-8000-000000000003', 'milo_cat', 'MiloDaCat', '+8613800000003', NULL, 'active', now() - interval '90 days');
INSERT INTO identity.user_credentials (user_id, password_hash, hash_algorithm)
VALUES
('11000000-0000-7000-8000-000000000001', '$2y$12$UYCn5ZmWnouv7BFyuvsTD.NvcA/A/VhHdCePbeIrvcsI4JoRBbmYq', 'bcrypt'),
('11000000-0000-7000-8000-000000000002', '$2y$12$UYCn5ZmWnouv7BFyuvsTD.NvcA/A/VhHdCePbeIrvcsI4JoRBbmYq', 'bcrypt'),
('11000000-0000-7000-8000-000000000003', '$2y$12$UYCn5ZmWnouv7BFyuvsTD.NvcA/A/VhHdCePbeIrvcsI4JoRBbmYq', 'bcrypt');
INSERT INTO identity.auth_sessions
(id, user_id, token_family_id, refresh_token_hash, access_token_jti, device_id, device_name, expires_at)
VALUES
(
'11100000-0000-7000-8000-000000000001',
'11000000-0000-7000-8000-000000000001',
'11100000-0000-7000-9000-000000000001',
decode(repeat('ab', 32), 'hex'),
'fixture-access-token-jti',
'fixture-device-1',
'Flutter test device',
now() + interval '30 days'
);
INSERT INTO identity.user_preferences
(user_id, default_region_id, allow_precise_location)
VALUES
('11000000-0000-7000-8000-000000000001', '10000000-0000-7000-8000-000000000001', false),
('11000000-0000-7000-8000-000000000002', '10000000-0000-7000-8000-000000000001', false),
('11000000-0000-7000-8000-000000000003', '10000000-0000-7000-8000-000000000002', false);
INSERT INTO identity.user_addresses
(id, user_id, region_id, label, recipient_name, recipient_phone_e164, address_line, latitude, longitude, is_default)
VALUES
(
'11200000-0000-7000-8000-000000000001',
'11000000-0000-7000-8000-000000000001',
'10000000-0000-7000-8000-000000000001',
'家', '豆豆家长', '+8613800000001', '示例路 88 号', 39.922100, 116.443900, true
);
INSERT INTO media.assets
(id, owner_user_id, kind, purpose, storage_type, external_url, mime_type, width_px, height_px, duration_ms, status, ready_at)
VALUES
('12000000-0000-7000-8000-000000000001', '11000000-0000-7000-8000-000000000001', 'image', 'user_avatar', 'external', 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=300&q=80', 'image/jpeg', 300, 300, NULL, 'ready', now()),
('12000000-0000-7000-8000-000000000002', '11000000-0000-7000-8000-000000000002', 'image', 'user_avatar', 'external', 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=200&q=80', 'image/jpeg', 200, 200, NULL, 'ready', now()),
('12000000-0000-7000-8000-000000000003', '11000000-0000-7000-8000-000000000003', 'image', 'user_avatar', 'external', 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=200&q=80', 'image/jpeg', 200, 200, NULL, 'ready', now()),
('12000000-0000-7000-8000-000000000004', '11000000-0000-7000-8000-000000000001', 'image', 'pet_avatar', 'external', 'https://images.unsplash.com/photo-1552053831-71594a27632d?auto=format&fit=crop&w=600&q=85', 'image/jpeg', 600, 600, NULL, 'ready', now()),
('12000000-0000-7000-8000-000000000005', '11000000-0000-7000-8000-000000000001', 'image', 'generation_output', 'external', 'https://images.unsplash.com/photo-1605568427561-40dd23c2acea?auto=format&fit=crop&w=1200&q=85', 'image/jpeg', 1200, 900, NULL, 'ready', now()),
('12000000-0000-7000-8000-000000000006', '11000000-0000-7000-8000-000000000003', 'image', 'post', 'external', 'https://images.unsplash.com/photo-1574158622682-e40e69881006?auto=format&fit=crop&w=1200&q=85', 'image/jpeg', 1200, 900, NULL, 'ready', now()),
('12000000-0000-7000-8000-000000000007', '11000000-0000-7000-8000-000000000001', 'image', 'provider_cover', 'external', 'https://images.unsplash.com/photo-1628009368231-7bb7cfcb0def?auto=format&fit=crop&w=900&q=85', 'image/jpeg', 900, 500, NULL, 'ready', now()),
('12000000-0000-7000-8000-000000000008', '11000000-0000-7000-8000-000000000002', 'image', 'provider_cover', 'external', 'https://images.unsplash.com/photo-1516734212186-a967f81ad0d7?auto=format&fit=crop&w=900&q=85', 'image/jpeg', 900, 500, NULL, 'ready', now()),
('12000000-0000-7000-8000-000000000009', '11000000-0000-7000-8000-000000000003', 'image', 'provider_cover', 'external', 'https://images.unsplash.com/photo-1601758228041-f3b2795255f1?auto=format&fit=crop&w=900&q=85', 'image/jpeg', 900, 500, NULL, 'ready', now()),
('12000000-0000-7000-8000-000000000010', '11000000-0000-7000-8000-000000000001', 'document', 'health_certificate', 'external', 'https://example.invalid/fixtures/doudou-vaccine-certificate.pdf', 'application/pdf', NULL, NULL, NULL, 'ready', now());
UPDATE identity.users SET avatar_asset_id = '12000000-0000-7000-8000-000000000001'
WHERE id = '11000000-0000-7000-8000-000000000001';
UPDATE identity.users SET avatar_asset_id = '12000000-0000-7000-8000-000000000002'
WHERE id = '11000000-0000-7000-8000-000000000002';
UPDATE identity.users SET avatar_asset_id = '12000000-0000-7000-8000-000000000003'
WHERE id = '11000000-0000-7000-8000-000000000003';
INSERT INTO pet_health.breeds (id, species, code, display_name, sort_order)
VALUES
('13000000-0000-7000-8000-000000000001', 'dog', 'shiba-inu', '柴犬', 10),
('13000000-0000-7000-8000-000000000002', 'dog', 'golden-retriever', '金毛寻回犬', 20),
('13000000-0000-7000-8000-000000000003', 'dog', 'corgi', '柯基', 30),
('13000000-0000-7000-8000-000000000004', 'cat', 'british-shorthair', '英国短毛猫', 10);
INSERT INTO pet_health.pets
(id, name, species, breed_id, sex, birth_date, personality, avatar_asset_id, microchip_no, sterilized_on)
VALUES
(
'14000000-0000-7000-8000-000000000001', '豆豆', 'dog',
'13000000-0000-7000-8000-000000000001', 'male', DATE '2024-05-15', '活泼',
'12000000-0000-7000-8000-000000000004', 'CHIP-DEMO-0001', DATE '2025-01-10'
);
INSERT INTO pet_health.pet_owners (pet_id, user_id, role, is_primary)
VALUES
('14000000-0000-7000-8000-000000000001', '11000000-0000-7000-8000-000000000001', 'owner', true);
INSERT INTO pet_health.pet_weight_records
(id, pet_id, weight_kg, measured_at, source, note)
VALUES
('14100000-0000-7000-8000-000000000001', '14000000-0000-7000-8000-000000000001', 4.80, now() - interval '90 days', 'manual', '成长记录'),
('14100000-0000-7000-8000-000000000002', '14000000-0000-7000-8000-000000000001', 5.20, now() - interval '1 day', 'manual', '当前体重');
INSERT INTO marketplace.providers
(id, owner_user_id, kind, slug, name, description, region_id, address_line, latitude, longitude,
cover_asset_id, verification_status, verified_at, is_24h, rating_avg, rating_count, status)
VALUES
(
'16000000-0000-7000-8000-000000000001', NULL, 'hospital', 'anchong-animal-hospital',
'安宠动物医院(朝阳旗舰店)', '猫科内科 · 骨外科 · 预防医学 · 24H急诊',
'10000000-0000-7000-8000-000000000001', '示例医疗街 10 号', 39.927800, 116.450900,
'12000000-0000-7000-8000-000000000007',
'verified', now() - interval '1 year', true, 4.90, 138, 'active'
),
(
'16000000-0000-7000-8000-000000000002', NULL, 'grooming', 'pawspa-chaoyang',
'PawSpa 高级宠物沙龙', '资深美容师主理 · 环境静音 · 猫咪免应激',
'10000000-0000-7000-8000-000000000001', '示例商业街 28 号', 39.915900, 116.430800,
'12000000-0000-7000-8000-000000000008',
'verified', now() - interval '200 days', false, 4.90, 86, 'active'
),
(
'16000000-0000-7000-8000-000000000003', '11000000-0000-7000-8000-000000000003', 'personal', 'sunny-dog-walker',
'阳光遛狗员-阿健', '资深铲屎官,提供耐心、安全的上门遛狗服务。',
'10000000-0000-7000-8000-000000000001', '朝阳区上门服务', 39.919500, 116.439200,
'12000000-0000-7000-8000-000000000009',
'verified', now() - interval '100 days', false, 4.90, 42, 'active'
);
INSERT INTO marketplace.provider_tags (provider_id, tag)
VALUES
('16000000-0000-7000-8000-000000000001', '猫专科认证'),
('16000000-0000-7000-8000-000000000001', '24H急诊'),
('16000000-0000-7000-8000-000000000002', '日系精修'),
('16000000-0000-7000-8000-000000000002', '猫咪免应激'),
('16000000-0000-7000-8000-000000000003', '实时定位'),
('16000000-0000-7000-8000-000000000003', '自带清洁包'),
('16000000-0000-7000-8000-000000000003', '只接中小型犬');
INSERT INTO marketplace.service_offerings
(id, provider_id, category, name, description, base_price_cents, pricing_unit, duration_minutes)
VALUES
('16100000-0000-7000-8000-000000000001', '16000000-0000-7000-8000-000000000001', 'hospital', '宠物基础问诊', '基础体格检查与问诊', 29900, 'visit', 30),
('16100000-0000-7000-8000-000000000002', '16000000-0000-7000-8000-000000000002', 'grooming', '宠物精洗美容', '洗护及基础造型', 15800, 'service', 90),
('16100000-0000-7000-8000-000000000003', '16000000-0000-7000-8000-000000000003', 'walking', '上门遛狗 60 分钟', '实时定位并反馈照片', 4500, 'hour', 60);
INSERT INTO marketplace.service_resources
(id, provider_id, name, resource_type)
VALUES
('16200000-0000-7000-8000-000000000001', '16000000-0000-7000-8000-000000000001', '问诊室 A', 'room'),
('16200000-0000-7000-8000-000000000002', '16000000-0000-7000-8000-000000000002', '美容师小林', 'person'),
('16200000-0000-7000-8000-000000000003', '16000000-0000-7000-8000-000000000003', '阿健', 'person');
INSERT INTO marketplace.offering_resources (provider_id, offering_id, resource_id)
VALUES
('16000000-0000-7000-8000-000000000001', '16100000-0000-7000-8000-000000000001', '16200000-0000-7000-8000-000000000001'),
('16000000-0000-7000-8000-000000000002', '16100000-0000-7000-8000-000000000002', '16200000-0000-7000-8000-000000000002'),
('16000000-0000-7000-8000-000000000003', '16100000-0000-7000-8000-000000000003', '16200000-0000-7000-8000-000000000003');
INSERT INTO marketplace.bookings
(id, customer_user_id, pet_id, provider_id, offering_id, resource_id,
scheduled_start_at, scheduled_end_at, status,
provider_name_snapshot, service_name_snapshot, pet_name_snapshot, address_snapshot,
base_amount_cents, discount_amount_cents, payable_amount_cents, idempotency_key, request_hash)
VALUES
(
'16300000-0000-7000-8000-000000000001',
'11000000-0000-7000-8000-000000000001', '14000000-0000-7000-8000-000000000001',
'16000000-0000-7000-8000-000000000003', '16100000-0000-7000-8000-000000000003',
'16200000-0000-7000-8000-000000000003',
timestamptz '2030-08-20 09:00:00+08', timestamptz '2030-08-20 10:00:00+08', 'confirmed',
'阳光遛狗员-阿健', '上门遛狗 60 分钟', '豆豆', '北京市朝阳区上门服务',
4500, 2000, 2500, 'fixture-booking-1', decode(repeat('31', 32), 'hex')
);
INSERT INTO marketplace.booking_status_history
(booking_id, from_status, to_status, actor_user_id, reason)
VALUES
('16300000-0000-7000-8000-000000000001', NULL, 'confirmed', '11000000-0000-7000-8000-000000000001', '开发测试预约');
INSERT INTO pet_health.vaccine_catalog (id, code, name, species)
VALUES
('15000000-0000-7000-8000-000000000001', 'dog-combo-5', '犬五联', 'dog'),
('15000000-0000-7000-8000-000000000002', 'rabies', '狂犬疫苗', 'dog');
INSERT INTO pet_health.pet_vaccinations
(id, pet_id, vaccine_id, series_key, dose_no, dose_label, status, planned_on, administered_on,
next_due_on, provider_id, provider_name_snapshot, certificate_asset_id)
VALUES
(
'15100000-0000-7000-8000-000000000001', '14000000-0000-7000-8000-000000000001',
'15000000-0000-7000-8000-000000000001', '2025-primary', 1, '第1针', 'completed',
DATE '2025-05-12', DATE '2025-05-12', DATE '2025-07-15',
'16000000-0000-7000-8000-000000000001', '安宠动物医院(朝阳旗舰店)',
'12000000-0000-7000-8000-000000000010'
),
(
'15100000-0000-7000-8000-000000000002', '14000000-0000-7000-8000-000000000001',
'15000000-0000-7000-8000-000000000002', '2025-rabies', 1, '年度接种', 'completed',
DATE '2025-06-15', DATE '2025-06-15', DATE '2026-06-15',
'16000000-0000-7000-8000-000000000001', '安宠动物医院(朝阳旗舰店)', NULL
),
(
'15100000-0000-7000-8000-000000000003', '14000000-0000-7000-8000-000000000001',
'15000000-0000-7000-8000-000000000001', '2025-primary', 2, '加强针', 'scheduled',
DATE '2026-08-15', NULL, NULL, NULL, NULL, NULL
);
INSERT INTO pet_health.health_events
(id, pet_id, event_type, occurred_at, title, notes, amount_cents, provider_id, provider_name_snapshot, created_by_user_id)
VALUES
(
'15200000-0000-7000-8000-000000000002', '14000000-0000-7000-8000-000000000001',
'feeding', timestamptz '2025-05-20 18:00:00+08', '更换幼犬粮', '体重增长稳定', 20000,
NULL, NULL, '11000000-0000-7000-8000-000000000001'
);
INSERT INTO pet_health.care_reminders
(id, pet_id, reminder_type, title, due_at)
VALUES
(
'15300000-0000-7000-8000-000000000002', '14000000-0000-7000-8000-000000000001',
'deworming', '安排体内外驱虫', timestamptz '2026-08-16 09:00:00+08'
);
INSERT INTO creation.generation_models
(id, code, display_name, provider_code, provider_model_name, media_kind, sort_order)
VALUES
('17000000-0000-7000-8000-000000000001', 'patbond-v1', 'Patbond-V1', 'fixture', 'patbond-image-v1', 'image', 10),
('17000000-0000-7000-8000-000000000002', 'patbond-v1', 'Patbond-V1', 'fixture', 'patbond-video-v1', 'video', 10);
INSERT INTO creation.generation_styles
(id, code, title, subtitle, media_kind, preview_asset_id, sort_order)
VALUES
('17100000-0000-7000-8000-000000000001', 'healing', '治愈动画', '梦幻动漫感', 'image', '12000000-0000-7000-8000-000000000005', 10),
('17100000-0000-7000-8000-000000000002', '3d-cartoon', '3D 卡通', '经典渲染', 'image', '12000000-0000-7000-8000-000000000005', 20),
('17100000-0000-7000-8000-000000000003', 'comic', '漫画风', '帅气网格', 'image', '12000000-0000-7000-8000-000000000005', 30),
('17100000-0000-7000-8000-000000000004', 'watercolor', '水彩', '柔和手绘质感', 'image', '12000000-0000-7000-8000-000000000005', 40),
('17100000-0000-7000-8000-000000000005', 'healing', '治愈短片', '梦幻动态效果', 'video', '12000000-0000-7000-8000-000000000005', 10);
INSERT INTO creation.generation_jobs
(id, user_id, pet_id, media_kind, model_id, style_id, input_asset_id, output_asset_id,
prompt, width_px, height_px, upscale, parameters, status, progress, attempt_count,
provider_code_snapshot, provider_model_snapshot, model_version_snapshot, style_code_snapshot,
provider_request_id, idempotency_key, request_hash, created_at, started_at, completed_at)
VALUES
(
'17200000-0000-7000-8000-000000000001', '11000000-0000-7000-8000-000000000001',
'14000000-0000-7000-8000-000000000001', 'image',
'17000000-0000-7000-8000-000000000001', '17100000-0000-7000-8000-000000000001',
'12000000-0000-7000-8000-000000000004', '12000000-0000-7000-8000-000000000005',
'把豆豆生成治愈系插画', 1920, 1080, true, '{}'::jsonb,
'succeeded', 100, 1, 'fixture', 'patbond-image-v1', '1.0', 'healing',
'fixture-provider-job-1', 'fixture-generation-1', decode(repeat('41', 32), 'hex'),
now() - interval '3 hours', now() - interval '2 hours 59 minutes', now() - interval '2 hours 55 minutes'
);
INSERT INTO community.posts
(id, author_user_id, pet_id, generation_job_id, category, title, content, status, visibility,
region_id, location_text_snapshot, like_count, comment_count, bookmark_count,
idempotency_key, request_hash, created_at, published_at)
VALUES
(
'18000000-0000-7000-8000-000000000001', '11000000-0000-7000-8000-000000000001',
'14000000-0000-7000-8000-000000000001', '17200000-0000-7000-8000-000000000001',
'ai_creation', '豆豆的治愈动画冒险',
'用 AI 把豆豆生成了治愈系插画,太可爱了吧!大家也快去试试创作功能!✨',
'published', 'public', '10000000-0000-7000-8000-000000000001', '北京市 · 朝阳区',
2, 2, 1, 'fixture-post-1', decode(repeat('51', 32), 'hex'),
now() - interval '2 hours', now() - interval '2 hours'
),
(
'18000000-0000-7000-8000-000000000002', '11000000-0000-7000-8000-000000000003',
NULL, NULL, 'help', NULL, '猫咪最近总是挠耳朵,有懂的朋友吗?',
'published', 'public', '10000000-0000-7000-8000-000000000002', '上海市 · 浦东新区',
1, 1, 0, 'fixture-post-2', decode(repeat('52', 32), 'hex'),
now() - interval '5 hours', now() - interval '5 hours'
);
INSERT INTO community.post_media (post_id, position, asset_id, is_cover)
VALUES
('18000000-0000-7000-8000-000000000001', 0, '12000000-0000-7000-8000-000000000005', true),
('18000000-0000-7000-8000-000000000002', 0, '12000000-0000-7000-8000-000000000006', true);
INSERT INTO community.comments
(id, post_id, author_user_id, content, client_request_id, request_hash, created_at)
VALUES
(
'18100000-0000-7000-8000-000000000001', '18000000-0000-7000-8000-000000000001',
'11000000-0000-7000-8000-000000000002', '这也太像了吧!求教程怎么生成的?', 'fixture-comment-1', decode(repeat('61', 32), 'hex'),
now() - interval '1 hour'
),
(
'18100000-0000-7000-8000-000000000002', '18000000-0000-7000-8000-000000000001',
'11000000-0000-7000-8000-000000000003', '好可爱的画风,豆豆真上镜!', 'fixture-comment-2', decode(repeat('62', 32), 'hex'),
now() - interval '45 minutes'
),
(
'18100000-0000-7000-8000-000000000003', '18000000-0000-7000-8000-000000000002',
'11000000-0000-7000-8000-000000000002', '建议先去医院检查耳道,不要自行用药哦。', 'fixture-comment-3', decode(repeat('63', 32), 'hex'),
now() - interval '4 hours'
);
INSERT INTO community.post_likes (post_id, user_id, created_at)
VALUES
('18000000-0000-7000-8000-000000000001', '11000000-0000-7000-8000-000000000002', now() - interval '1 hour'),
('18000000-0000-7000-8000-000000000001', '11000000-0000-7000-8000-000000000003', now() - interval '30 minutes'),
('18000000-0000-7000-8000-000000000002', '11000000-0000-7000-8000-000000000001', now() - interval '3 hours');
INSERT INTO community.post_bookmarks (post_id, user_id)
VALUES
('18000000-0000-7000-8000-000000000001', '11000000-0000-7000-8000-000000000002');
INSERT INTO community.user_follows (follower_user_id, followee_user_id)
VALUES
('11000000-0000-7000-8000-000000000002', '11000000-0000-7000-8000-000000000001'),
('11000000-0000-7000-8000-000000000003', '11000000-0000-7000-8000-000000000001');
INSERT INTO community.topics (id, name)
VALUES
('18200000-0000-7000-8000-000000000001', 'AI创作'),
('18200000-0000-7000-8000-000000000002', '柴犬'),
('18200000-0000-7000-8000-000000000003', 'Patbond'),
('18200000-0000-7000-8000-000000000004', '猫咪健康'),
('18200000-0000-7000-8000-000000000005', '求助');
INSERT INTO community.post_topics (post_id, topic_id)
VALUES
('18000000-0000-7000-8000-000000000001', '18200000-0000-7000-8000-000000000001'),
('18000000-0000-7000-8000-000000000001', '18200000-0000-7000-8000-000000000002'),
('18000000-0000-7000-8000-000000000001', '18200000-0000-7000-8000-000000000003'),
('18000000-0000-7000-8000-000000000002', '18200000-0000-7000-8000-000000000004'),
('18000000-0000-7000-8000-000000000002', '18200000-0000-7000-8000-000000000005');
INSERT INTO platform.notifications
(id, user_id, notification_type, title, body, target_type, target_id)
VALUES
(
'19000000-0000-7000-8000-000000000001', '11000000-0000-7000-8000-000000000001',
'health.reminder', '豆豆的健康提醒', '犬五联加强针将在 2026-08-15 到期。',
'vaccination', '15100000-0000-7000-8000-000000000003'
);
INSERT INTO platform.outbox_events
(id, aggregate_type, aggregate_id, aggregate_version, event_type, payload)
VALUES
(
'19100000-0000-7000-8000-000000000001', 'generation_job',
'17200000-0000-7000-8000-000000000001', 1, 'creation.generation_succeeded',
'{"fixture":true,"outputAssetId":"12000000-0000-7000-8000-000000000005"}'::jsonb
);
ANALYZE;
/* -------------------------------------------------------------------------
Executable integrity checks. Any unexpected condition aborts the transaction.
--------------------------------------------------------------------------- */
DO $$
DECLARE
rejected boolean := false;
BEGIN
BEGIN
INSERT INTO identity.users (username, nickname) VALUES ('DEMO_USER', '重复用户名');
EXCEPTION WHEN unique_violation THEN
rejected := true;
END;
IF NOT rejected THEN
RAISE EXCEPTION 'validation failed: username uniqueness is not case-insensitive';
END IF;
END;
$$;
DO $$
DECLARE
rejected boolean := false;
BEGIN
BEGIN
INSERT INTO pet_health.pet_weight_records (pet_id, weight_kg, measured_at)
VALUES ('14000000-0000-7000-8000-000000000001', -1, now());
EXCEPTION WHEN check_violation THEN
rejected := true;
END;
IF NOT rejected THEN
RAISE EXCEPTION 'validation failed: negative pet weight was accepted';
END IF;
END;
$$;
DO $$
DECLARE
rejected boolean := false;
BEGIN
BEGIN
INSERT INTO identity.user_addresses
(user_id, region_id, label, address_line, latitude, longitude)
VALUES
(
'11000000-0000-7000-8000-000000000001',
'10000000-0000-7000-8000-000000000001',
'错误坐标', '只填写经度的地址', NULL, 116.443550
);
EXCEPTION WHEN check_violation THEN
rejected := true;
END;
IF NOT rejected THEN
RAISE EXCEPTION 'validation failed: one-sided coordinates were accepted';
END IF;
END;
$$;
DO $$
DECLARE
rejected boolean := false;
BEGIN
BEGIN
INSERT INTO creation.generation_jobs
(user_id, media_kind, model_id, style_id, input_asset_id,
provider_code_snapshot, provider_model_snapshot, model_version_snapshot, style_code_snapshot,
idempotency_key, request_hash)
VALUES
(
'11000000-0000-7000-8000-000000000001', 'video',
'17000000-0000-7000-8000-000000000001',
'17100000-0000-7000-8000-000000000005',
'12000000-0000-7000-8000-000000000004',
'fixture', 'wrong-kind-test', '1.0', 'healing',
'fixture-wrong-kind-must-fail', decode(repeat('71', 32), 'hex')
);
EXCEPTION WHEN foreign_key_violation THEN
rejected := true;
END;
IF NOT rejected THEN
RAISE EXCEPTION 'validation failed: AI job accepted a model of the wrong media kind';
END IF;
END;
$$;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM community.posts p
LEFT JOIN LATERAL (
SELECT count(*) AS value FROM community.post_likes l WHERE l.post_id = p.id
) likes ON true
LEFT JOIN LATERAL (
SELECT count(*) AS value FROM community.comments c
WHERE c.post_id = p.id AND c.status = 'visible'
) comments ON true
LEFT JOIN LATERAL (
SELECT count(*) AS value FROM community.post_bookmarks b WHERE b.post_id = p.id
) bookmarks ON true
WHERE p.like_count <> likes.value
OR p.comment_count <> comments.value
OR p.bookmark_count <> bookmarks.value
) THEN
RAISE EXCEPTION 'validation failed: post counter cache does not match relation facts';
END IF;
END;
$$;
DO $$
DECLARE
rejected boolean := false;
BEGIN
BEGIN
INSERT INTO marketplace.bookings
(customer_user_id, pet_id, provider_id, offering_id, resource_id,
scheduled_start_at, scheduled_end_at, status,
provider_name_snapshot, service_name_snapshot, pet_name_snapshot, address_snapshot,
base_amount_cents, discount_amount_cents, payable_amount_cents, idempotency_key, request_hash)
VALUES
(
'11000000-0000-7000-8000-000000000001', '14000000-0000-7000-8000-000000000001',
'16000000-0000-7000-8000-000000000003', '16100000-0000-7000-8000-000000000003',
'16200000-0000-7000-8000-000000000003',
timestamptz '2030-08-20 09:30:00+08', timestamptz '2030-08-20 10:30:00+08', 'confirmed',
'阳光遛狗员-阿健', '上门遛狗 60 分钟', '豆豆', '北京市朝阳区上门服务',
4500, 0, 4500, 'fixture-overlap-must-fail', decode(repeat('72', 32), 'hex')
);
EXCEPTION WHEN exclusion_violation THEN
rejected := true;
END;
IF NOT rejected THEN
RAISE EXCEPTION 'validation failed: overlapping booking was accepted';
END IF;
END;
$$;
DO $$
DECLARE
inserted_id uuid;
BEGIN
INSERT INTO marketplace.bookings
(customer_user_id, pet_id, provider_id, offering_id, resource_id,
scheduled_start_at, scheduled_end_at, status,
provider_name_snapshot, service_name_snapshot, pet_name_snapshot, address_snapshot,
base_amount_cents, discount_amount_cents, payable_amount_cents, idempotency_key, request_hash)
VALUES
(
'11000000-0000-7000-8000-000000000001', '14000000-0000-7000-8000-000000000001',
'16000000-0000-7000-8000-000000000003', '16100000-0000-7000-8000-000000000003',
'16200000-0000-7000-8000-000000000003',
timestamptz '2030-08-20 10:00:00+08', timestamptz '2030-08-20 11:00:00+08', 'confirmed',
'阳光遛狗员-阿健', '上门遛狗 60 分钟', '豆豆', '北京市朝阳区上门服务',
4500, 0, 4500, 'fixture-adjacent-booking', decode(repeat('73', 32), 'hex')
)
RETURNING id INTO inserted_id;
DELETE FROM marketplace.bookings WHERE id = inserted_id;
END;
$$;
DO $$
DECLARE
missing_indexes text;
BEGIN
WITH foreign_keys AS (
SELECT c.oid, c.conname, c.conrelid, c.conkey
FROM pg_constraint c
JOIN pg_namespace n ON n.oid = c.connamespace
WHERE c.contype = 'f'
AND n.nspname IN ('platform', 'identity', 'media', 'pet_health', 'community', 'creation', 'marketplace')
), missing AS (
SELECT fk.conname
FROM foreign_keys fk
WHERE NOT EXISTS (
SELECT 1
FROM pg_index i
WHERE i.indrelid = fk.conrelid
AND i.indisvalid
AND i.indpred IS NULL
AND fk.conkey = (
SELECT array_agg(k.attnum ORDER BY k.ordinality)::smallint[]
FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, ordinality)
WHERE k.ordinality <= cardinality(fk.conkey)
)
)
)
SELECT string_agg(conname, ', ' ORDER BY conname) INTO missing_indexes FROM missing;
IF missing_indexes IS NOT NULL THEN
RAISE EXCEPTION 'validation failed: foreign keys without leading indexes: %', missing_indexes;
END IF;
END;
$$;
COMMIT;
/* -------------------------------------------------------------------------
Reference queries for the future API repositories
--------------------------------------------------------------------------- */
-- Pet health summary: latest weight + vaccine progress + next scheduled dose.
SELECT
p.id,
p.name,
latest_weight.weight_kg,
vaccine_summary.completed_doses,
vaccine_summary.total_doses,
vaccine_summary.next_planned_on
FROM pet_health.pets p
LEFT JOIN LATERAL (
SELECT w.weight_kg
FROM pet_health.pet_weight_records w
WHERE w.pet_id = p.id
ORDER BY w.measured_at DESC, w.id DESC
LIMIT 1
) latest_weight ON true
LEFT JOIN LATERAL (
SELECT
count(*) FILTER (WHERE v.status = 'completed') AS completed_doses,
count(*) FILTER (WHERE v.status <> 'cancelled') AS total_doses,
min(v.planned_on) FILTER (WHERE v.status = 'scheduled') AS next_planned_on
FROM pet_health.pet_vaccinations v
WHERE v.pet_id = p.id
) vaccine_summary ON true
WHERE p.status = 'active';
-- Nearby providers: first use the indexed bounding box, then exact Haversine.
-- The +/- 0.10 degree fixture box is illustrative; the API must calculate a
-- latitude-aware box from the requested radius before binding these values.
WITH candidates AS (
SELECT
p.id,
p.name,
p.kind,
p.rating_avg,
platform.distance_km(39.921900, 116.443550, p.latitude, p.longitude) AS distance_km
FROM marketplace.providers p
WHERE p.status = 'active'
AND p.region_id = '10000000-0000-7000-8000-000000000001'
AND p.latitude BETWEEN 39.821900 AND 40.021900
AND p.longitude BETWEEN 116.343550 AND 116.543550
)
SELECT id, name, kind, rating_avg, distance_km
FROM candidates
WHERE distance_km <= 10.0
ORDER BY distance_km, rating_avg DESC, id
LIMIT 20;
-- Cursor feed query; never use deep OFFSET pagination.
SELECT p.*
FROM community.posts p
WHERE p.status = 'published'
AND p.visibility = 'public'
AND (p.published_at, p.id) < (now(), 'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid)
ORDER BY p.published_at DESC, p.id DESC
LIMIT 20;
/*
Atomic like write used by the backend (placeholders intentionally commented):
WITH inserted AS (
INSERT INTO community.post_likes(post_id, user_id)
VALUES (:post_id, :user_id)
ON CONFLICT DO NOTHING
RETURNING post_id
)
UPDATE community.posts p
SET like_count = p.like_count + 1
FROM inserted i
WHERE p.id = i.post_id
RETURNING p.like_count;
AI worker claim pattern for multiple concurrent workers:
WITH picked AS (
SELECT id
FROM creation.generation_jobs
WHERE status = 'queued'
AND attempt_count < max_attempts
AND next_attempt_at <= now()
ORDER BY priority DESC, next_attempt_at, created_at, id
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE creation.generation_jobs j
SET status = 'running', started_at = now(), progress = 1,
attempt_count = attempt_count + 1,
lease_owner = :worker_id, lease_expires_at = now() + interval '2 minutes',
version = version + 1
FROM picked
WHERE j.id = picked.id
RETURNING j.*;
Held-booking sweeper. This worker is mandatory because CHECK constraints must
not use volatile now() in a partial index or exclusion predicate:
WITH expired AS (
SELECT id
FROM marketplace.bookings
WHERE status = 'held' AND hold_expires_at <= now()
ORDER BY hold_expires_at, id
FOR UPDATE SKIP LOCKED
LIMIT 100
)
UPDATE marketplace.bookings b
SET status = 'expired', hold_expires_at = NULL, version = version + 1
FROM expired e
WHERE b.id = e.id
RETURNING b.id;
Pet authorization is a domain rule, not implied by a pet foreign key. Before
creating a post, AI job, or booking, the service must require an allowed row:
SELECT 1
FROM pet_health.pet_owners po
WHERE po.pet_id = :pet_id
AND po.user_id = :authenticated_user_id
AND po.role IN ('owner', 'caregiver');
Business aggregate updates use explicit optimistic compare-and-swap. High
frequency counters do not increment the content version:
UPDATE pet_health.pets
SET name = :name, version = version + 1
WHERE id = :pet_id AND version = :expected_version
RETURNING version;
*/