1951 lines
89 KiB
PL/PgSQL
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;
|
|
*/
|