Initial miaojingAI project with image resolution guard

This commit is contained in:
FengLee
2026-05-09 11:32:34 +08:00
commit d499020d4e
264 changed files with 54160 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
#!/bin/bash
set -Eeuo pipefail
COZE_WORKSPACE_PATH="${COZE_WORKSPACE_PATH:-$(pwd)}"
if [ -f "${COZE_WORKSPACE_PATH}/.env.local" ]; then
set +u
set -a
# shellcheck disable=SC1091
source "${COZE_WORKSPACE_PATH}/.env.local"
set +a
set -u
fi
if [ -z "${LOCAL_DB_URL:-}" ]; then
echo "LOCAL_DB_URL is not set" >&2
exit 1
fi
psql "${LOCAL_DB_URL}" -v ON_ERROR_STOP=1 -f "${COZE_WORKSPACE_PATH}/scripts/database-optimization-patch.sql"

72
scripts/backup-create.sh Normal file
View File

@@ -0,0 +1,72 @@
#!/bin/bash
set -Eeuo pipefail
COZE_WORKSPACE_PATH="${COZE_WORKSPACE_PATH:-$(pwd)}"
REQUESTED_BACKUP_DIR="${BACKUP_DIR:-}"
REQUESTED_LOCAL_DB_URL="${LOCAL_DB_URL:-}"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "${TMP_DIR}"
}
trap cleanup EXIT
cd "${COZE_WORKSPACE_PATH}"
if [ -f ".env.local" ]; then
set +u
set -a
# shellcheck disable=SC1091
source ".env.local"
set +a
set -u
fi
[ -n "${REQUESTED_LOCAL_DB_URL}" ] && LOCAL_DB_URL="${REQUESTED_LOCAL_DB_URL}"
BACKUP_DIR="${REQUESTED_BACKUP_DIR:-${BACKUP_DIR:-${COZE_WORKSPACE_PATH}/backups}}"
BACKUP_FILE="${BACKUP_DIR}/miaojing-backup-${TIMESTAMP}.tar.gz"
mkdir -p "${BACKUP_DIR}"
chmod 700 "${BACKUP_DIR}"
if [ -z "${LOCAL_DB_URL:-}" ]; then
echo "LOCAL_DB_URL is required in .env.local or environment." >&2
exit 1
fi
command -v pg_dump >/dev/null 2>&1 || {
echo "pg_dump is required to create backups." >&2
exit 1
}
pg_dump "${LOCAL_DB_URL}" --format=custom --file "${TMP_DIR}/database.dump"
STORAGE_SOURCE="${LOCAL_STORAGE_DIR:-${COZE_WORKSPACE_PATH}/local-storage}"
if [ -d "${STORAGE_SOURCE}" ]; then
cp -a "${STORAGE_SOURCE}" "${TMP_DIR}/local-storage"
fi
if [ -f ".env.local" ]; then
cp ".env.local" "${TMP_DIR}/.env.local"
fi
if [ -f "package.json" ]; then
cp "package.json" "${TMP_DIR}/package.json"
fi
cat > "${TMP_DIR}/manifest.json" <<EOF
{
"app": "miaojingAI",
"createdAt": "$(date -Iseconds)",
"hostname": "$(hostname)",
"includes": ["database.dump", "local-storage", ".env.local", "package.json"]
}
EOF
tar -czf "${BACKUP_FILE}" -C "${TMP_DIR}" .
chmod 600 "${BACKUP_FILE}"
find "${BACKUP_DIR}" -maxdepth 1 -name 'miaojing-backup-*.tar.gz' -type f \
-printf '%T@ %p\n' | sort -rn | awk 'NR>10 {print $2}' | xargs -r rm -f
echo "${BACKUP_FILE}"

32
scripts/backup-list.sh Normal file
View File

@@ -0,0 +1,32 @@
#!/bin/bash
set -Eeuo pipefail
COZE_WORKSPACE_PATH="${COZE_WORKSPACE_PATH:-$(pwd)}"
REQUESTED_BACKUP_DIR="${BACKUP_DIR:-}"
cd "${COZE_WORKSPACE_PATH}"
if [ -f ".env.local" ]; then
set +u
set -a
# shellcheck disable=SC1091
source ".env.local"
set +a
set -u
fi
BACKUP_DIR="${REQUESTED_BACKUP_DIR:-${BACKUP_DIR:-${COZE_WORKSPACE_PATH}/backups}}"
mkdir -p "${BACKUP_DIR}"
chmod 700 "${BACKUP_DIR}"
if ! compgen -G "${BACKUP_DIR}/miaojing-backup-*.tar.gz" >/dev/null; then
echo "No backups found in ${BACKUP_DIR}"
exit 0
fi
printf '%-40s %-12s %s\n' "FILE" "SIZE" "MODIFIED"
find "${BACKUP_DIR}" -maxdepth 1 -name 'miaojing-backup-*.tar.gz' -type f \
-printf '%T@ %f %s %TY-%Tm-%Td %TH:%TM\n' \
| sort -rn \
| awk '{printf "%-40s %-12s %s %s\n", $2, $3, $4, $5}'

65
scripts/backup-restore.sh Normal file
View File

@@ -0,0 +1,65 @@
#!/bin/bash
set -Eeuo pipefail
COZE_WORKSPACE_PATH="${COZE_WORKSPACE_PATH:-$(pwd)}"
BACKUP_FILE="${1:-}"
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "${TMP_DIR}"
}
trap cleanup EXIT
if [ -z "${BACKUP_FILE}" ]; then
echo "Usage: pnpm backup:restore <backup-file.tar.gz>" >&2
exit 2
fi
if [ ! -f "${BACKUP_FILE}" ]; then
echo "Backup file not found: ${BACKUP_FILE}" >&2
exit 2
fi
cd "${COZE_WORKSPACE_PATH}"
if [ -f ".env.local" ]; then
set +u
set -a
# shellcheck disable=SC1091
source ".env.local"
set +a
set -u
fi
if [ -z "${LOCAL_DB_URL:-}" ]; then
echo "LOCAL_DB_URL is required in .env.local or environment." >&2
exit 1
fi
command -v pg_restore >/dev/null 2>&1 || {
echo "pg_restore is required to restore backups." >&2
exit 1
}
tar -xzf "${BACKUP_FILE}" -C "${TMP_DIR}"
if [ ! -f "${TMP_DIR}/database.dump" ]; then
echo "Invalid backup: missing database.dump." >&2
exit 2
fi
pg_restore --clean --if-exists --no-owner --dbname "${LOCAL_DB_URL}" "${TMP_DIR}/database.dump"
if [ -d "${TMP_DIR}/local-storage" ]; then
STORAGE_TARGET="${LOCAL_STORAGE_DIR:-${COZE_WORKSPACE_PATH}/local-storage}"
rm -rf "${STORAGE_TARGET}"
mkdir -p "$(dirname "${STORAGE_TARGET}")"
cp -a "${TMP_DIR}/local-storage" "${STORAGE_TARGET}"
fi
if [ -f "${TMP_DIR}/.env.local" ]; then
cp "${TMP_DIR}/.env.local" ".env.local"
chmod 600 ".env.local"
fi
echo "Restore completed from ${BACKUP_FILE}"

21
scripts/build.sh Normal file
View File

@@ -0,0 +1,21 @@
#!/bin/bash
set -Eeuo pipefail
COZE_WORKSPACE_PATH="${COZE_WORKSPACE_PATH:-$(pwd)}"
cd "${COZE_WORKSPACE_PATH}"
if [ "${INSTALL_DEPS:-0}" = "1" ] || [ ! -d node_modules ]; then
echo "Installing dependencies..."
pnpm install --prefer-frozen-lockfile --prefer-offline --loglevel debug --reporter=append-only
else
echo "Skipping dependency install. Set INSTALL_DEPS=1 to force it."
fi
echo "Building the Next.js project..."
pnpm next build
echo "Bundling server with tsup..."
pnpm tsup src/server.ts --format cjs --platform node --target node20 --outDir dist --no-splitting --no-minify
echo "Build completed successfully!"

View File

@@ -0,0 +1,50 @@
#!/bin/bash
set -Eeuo pipefail
fail=0
search_pattern() {
local pattern="$1"
shift
if command -v rg >/dev/null 2>&1; then
rg -n "$pattern" "$@" || true
else
grep -RInE "$pattern" "$@" || true
fi
}
check_no_match() {
local label="$1"
local pattern="$2"
shift 2
local output
output="$(search_pattern "$pattern" "$@")"
if [ -n "$output" ]; then
echo "Boundary violation: ${label}" >&2
echo "$output" >&2
fail=1
fi
}
check_no_match \
"web module must not import server database/storage internals" \
"@/storage|@/lib/local-storage|@/lib/session-auth|@/lib/admin-auth|@/lib/runtime-env|@/lib/server-crypto" \
src/modules/web
check_no_match \
"console module must not import server database/storage internals directly" \
"@/storage|@/lib/local-storage|@/lib/runtime-env|@/lib/server-crypto" \
src/modules/console
check_no_match \
"shared module must not depend on app-specific modules" \
"@/modules/(web|console|api)|@/app/|@/components/admin" \
src/modules/shared
if [ "$fail" -ne 0 ]; then
exit 1
fi
echo "Module boundaries OK"

View File

@@ -0,0 +1,169 @@
-- Idempotent local PostgreSQL patch for production maintenance.
-- It creates missing application tables and adds indexes used by hot paths.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
CREATE TABLE IF NOT EXISTS works (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
title VARCHAR(255),
type VARCHAR(32) NOT NULL,
prompt TEXT,
negative_prompt TEXT,
params JSONB DEFAULT '{}'::jsonb,
result_url TEXT,
thumbnail_url TEXT,
width INTEGER,
height INTEGER,
duration NUMERIC(6, 2),
is_public BOOLEAN NOT NULL DEFAULT false,
likes_count INTEGER NOT NULL DEFAULT 0,
credits_cost INTEGER NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL DEFAULT 'completed',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
order_no VARCHAR(64) NOT NULL UNIQUE,
product_type VARCHAR(32) NOT NULL,
product_name VARCHAR(255) NOT NULL,
amount NUMERIC(10, 2) NOT NULL,
credits_amount INTEGER,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
payment_method VARCHAR(32),
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS user_api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
provider VARCHAR(64) NOT NULL,
api_url TEXT,
model_name VARCHAR(128),
api_key_encrypted TEXT NOT NULL,
api_key_preview VARCHAR(20),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS work_likes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
work_id UUID NOT NULL REFERENCES works(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS generation_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(16) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'queued',
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
result JSONB,
error TEXT,
user_id UUID,
provider VARCHAR(128),
model_name VARCHAR(255),
api_url TEXT,
progress JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS user_id UUID;
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS provider VARCHAR(128);
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS model_name VARCHAR(255);
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS api_url TEXT;
ALTER TABLE generation_jobs ADD COLUMN IF NOT EXISTS progress JSONB NOT NULL DEFAULT '{}'::jsonb;
CREATE INDEX IF NOT EXISTS works_user_created_idx ON works (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS works_public_status_created_idx ON works (is_public, status, created_at DESC);
CREATE INDEX IF NOT EXISTS works_public_status_likes_idx ON works (is_public, status, likes_count DESC);
CREATE INDEX IF NOT EXISTS works_type_created_idx ON works (type, created_at DESC);
CREATE INDEX IF NOT EXISTS works_status_created_idx ON works (status, created_at DESC);
CREATE INDEX IF NOT EXISTS credit_transactions_user_created_idx ON credit_transactions (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS credit_transactions_type_created_idx ON credit_transactions (type, created_at DESC);
CREATE INDEX IF NOT EXISTS orders_user_created_idx ON orders (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS orders_status_created_idx ON orders (status, created_at DESC);
CREATE INDEX IF NOT EXISTS orders_order_no_idx ON orders (order_no);
CREATE INDEX IF NOT EXISTS user_api_keys_user_active_idx ON user_api_keys (user_id, is_active);
CREATE INDEX IF NOT EXISTS user_api_keys_provider_idx ON user_api_keys (provider);
CREATE INDEX IF NOT EXISTS work_likes_user_id_idx ON work_likes (user_id);
CREATE INDEX IF NOT EXISTS work_likes_work_id_idx ON work_likes (work_id);
CREATE UNIQUE INDEX IF NOT EXISTS work_likes_user_work_uniq ON work_likes (user_id, work_id);
CREATE INDEX IF NOT EXISTS announcements_active_window_idx ON announcements (is_active, starts_at, expires_at);
CREATE INDEX IF NOT EXISTS profiles_email_trgm_idx ON profiles USING GIN (LOWER(email) gin_trgm_ops);
CREATE INDEX IF NOT EXISTS profiles_nickname_trgm_idx ON profiles USING GIN (LOWER(COALESCE(nickname, '')) gin_trgm_ops);
CREATE INDEX IF NOT EXISTS profiles_phone_trgm_idx ON profiles USING GIN (LOWER(COALESCE(phone, '')) gin_trgm_ops);
CREATE INDEX IF NOT EXISTS generation_jobs_status_created_idx ON generation_jobs (status, created_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_status_updated_idx ON generation_jobs (status, updated_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_running_timeout_idx ON generation_jobs (updated_at) WHERE status = 'running';
CREATE INDEX IF NOT EXISTS generation_jobs_created_idx ON generation_jobs (created_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_user_created_idx ON generation_jobs (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_provider_model_created_idx ON generation_jobs (type, provider, model_name, created_at DESC);
CREATE TABLE IF NOT EXISTS platform_log_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
retention_days INTEGER NOT NULL DEFAULT 30,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO platform_log_settings (id, retention_days)
VALUES (1, 30)
ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS platform_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(32) NOT NULL,
level VARCHAR(16) NOT NULL DEFAULT 'info',
action VARCHAR(128) NOT NULL,
message TEXT NOT NULL,
user_id UUID,
user_name VARCHAR(255),
user_email VARCHAR(255),
target_type VARCHAR(64),
target_id VARCHAR(255),
ip_address VARCHAR(64),
user_agent TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS platform_logs_type_created_idx ON platform_logs (type, created_at DESC);
CREATE INDEX IF NOT EXISTS platform_logs_level_created_idx ON platform_logs (level, created_at DESC);
CREATE INDEX IF NOT EXISTS platform_logs_user_created_idx ON platform_logs (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS platform_logs_created_idx ON platform_logs (created_at DESC);
CREATE INDEX IF NOT EXISTS platform_logs_user_name_idx ON platform_logs (LOWER(COALESCE(user_name, '')));
CREATE INDEX IF NOT EXISTS platform_logs_user_email_idx ON platform_logs (LOWER(COALESCE(user_email, '')));
DROP POLICY IF EXISTS "site_config_write_auth" ON site_config;
DROP POLICY IF EXISTS "announcements_write_auth" ON announcements;
DROP POLICY IF EXISTS "site_stats_write_auth" ON site_stats;
DROP POLICY IF EXISTS "site_config_admin_write" ON site_config;
DROP POLICY IF EXISTS "announcements_admin_write" ON announcements;
CREATE POLICY "site_config_admin_write" ON site_config FOR ALL USING (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "announcements_admin_write" ON announcements FOR ALL USING (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
ANALYZE;

1156
scripts/deploy-or-upgrade.sh Normal file

File diff suppressed because it is too large Load Diff

34
scripts/dev.sh Normal file
View File

@@ -0,0 +1,34 @@
#!/bin/bash
set -Eeuo pipefail
PORT=${1:-5000}
COZE_WORKSPACE_PATH="${COZE_WORKSPACE_PATH:-$(pwd)}"
DEPLOY_RUN_PORT=$PORT
cd "${COZE_WORKSPACE_PATH}"
kill_port_if_listening() {
local pids
pids=$(ss -H -lntp 2>/dev/null | awk -v port="${DEPLOY_RUN_PORT}" '$4 ~ ":"port"$"' | grep -o 'pid=[0-9]*' | cut -d= -f2 | paste -sd' ' - || true)
if [[ -z "${pids}" ]]; then
echo "Port ${DEPLOY_RUN_PORT} is free."
return
fi
echo "Port ${DEPLOY_RUN_PORT} in use by PIDs: ${pids} (SIGKILL)"
echo "${pids}" | xargs -I {} kill -9 {}
sleep 1
pids=$(ss -H -lntp 2>/dev/null | awk -v port="${DEPLOY_RUN_PORT}" '$4 ~ ":"port"$"' | grep -o 'pid=[0-9]*' | cut -d= -f2 | paste -sd' ' - || true)
if [[ -n "${pids}" ]]; then
echo "Warning: port ${DEPLOY_RUN_PORT} still busy after SIGKILL, PIDs: ${pids}"
else
echo "Port ${DEPLOY_RUN_PORT} cleared."
fi
}
echo "Clearing port ${PORT} before start."
kill_port_if_listening
echo "Starting HTTP service on port ${PORT} for dev..."
PORT=$PORT pnpm tsx watch src/server.ts

663
scripts/init-database.sql Normal file
View File

@@ -0,0 +1,663 @@
-- ============================================================
-- 妙境 AI 创作平台 — 数据库初始化脚本
-- 适用于: PostgreSQL 14+ (Supabase / 自托管)
-- 执行方式: 在 Supabase SQL Editor 或 psql 中运行
-- ============================================================
-- 0. 启用必要扩展
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- 1. 创建 auth 模式和 users 表
CREATE SCHEMA IF NOT EXISTS auth;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'auth' AND p.proname = 'uid'
) THEN
EXECUTE 'CREATE FUNCTION auth.uid() RETURNS UUID AS $fn$ SELECT NULLIF(current_setting(''request.jwt.claim.sub'', true), '''')::UUID; $fn$ LANGUAGE SQL STABLE';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'auth' AND p.proname = 'role'
) THEN
EXECUTE 'CREATE FUNCTION auth.role() RETURNS TEXT AS $fn$ SELECT COALESCE(NULLIF(current_setting(''request.jwt.claim.role'', true), ''''), ''anon''); $fn$ LANGUAGE SQL STABLE';
END IF;
END $$;
CREATE TABLE IF NOT EXISTS auth.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE,
password_hash TEXT,
raw_user_meta_data JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS auth_users_email_idx ON auth.users (email);
-- ============================================================
-- 1. 用户资料表 (profiles)
-- 与 Supabase Auth 的 auth.users 表关联
-- ============================================================
CREATE TABLE IF NOT EXISTS profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
nickname VARCHAR(128),
avatar_url TEXT,
phone VARCHAR(20),
role VARCHAR(32) NOT NULL DEFAULT 'user', -- guest, user, vip, enterprise_admin, enterprise_member, admin
membership_tier VARCHAR(32) NOT NULL DEFAULT 'free', -- free, basic, pro, enterprise
membership_expires_at TIMESTAMPTZ,
credits_balance INTEGER NOT NULL DEFAULT 0,
daily_quota_used INTEGER NOT NULL DEFAULT 0,
daily_quota_limit INTEGER NOT NULL DEFAULT 5,
is_active BOOLEAN NOT NULL DEFAULT true,
email_verified BOOLEAN NOT NULL DEFAULT false,
email_verified_at TIMESTAMPTZ,
email_bound_at TIMESTAMPTZ,
email_sender_domain VARCHAR(255),
preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS profiles_email_idx ON profiles (email);
CREATE INDEX IF NOT EXISTS profiles_role_idx ON profiles (role);
-- ============================================================
-- 2. 创作作品表 (works)
-- ============================================================
CREATE TABLE IF NOT EXISTS works (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID DEFAULT auth.uid(),
title VARCHAR(255),
type VARCHAR(32) NOT NULL, -- text2img, img2img, text2video, img2video
prompt TEXT,
negative_prompt TEXT,
params JSONB, -- 生成参数 (画面比例、分辨率、模型等)
result_url TEXT, -- 生成文件的 URL
thumbnail_url TEXT,
width INTEGER,
height INTEGER,
duration NUMERIC(6, 2), -- 视频时长 (秒)
is_public BOOLEAN NOT NULL DEFAULT false,
likes_count INTEGER NOT NULL DEFAULT 0,
views_count INTEGER NOT NULL DEFAULT 0,
credits_cost INTEGER NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL DEFAULT 'completed', -- pending, processing, completed, failed
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS works_user_id_idx ON works (user_id);
CREATE INDEX IF NOT EXISTS works_type_idx ON works (type);
CREATE INDEX IF NOT EXISTS works_is_public_idx ON works (is_public);
CREATE INDEX IF NOT EXISTS works_created_at_idx ON works (created_at);
CREATE INDEX IF NOT EXISTS works_status_idx ON works (status);
-- ============================================================
-- 3. 积分记录表 (credit_transactions)
-- ============================================================
CREATE TABLE IF NOT EXISTS credit_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID DEFAULT auth.uid(),
amount INTEGER NOT NULL, -- 正数=入账, 负数=消费
balance_after INTEGER NOT NULL,
type VARCHAR(32) NOT NULL, -- purchase, consume, gift, reward, refund
description VARCHAR(500),
related_work_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS credit_transactions_user_id_idx ON credit_transactions (user_id);
CREATE INDEX IF NOT EXISTS credit_transactions_type_idx ON credit_transactions (type);
CREATE INDEX IF NOT EXISTS credit_transactions_created_at_idx ON credit_transactions (created_at);
-- ============================================================
-- 4. 订单表 (orders)
-- ============================================================
CREATE TABLE IF NOT EXISTS orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID DEFAULT auth.uid(),
order_no VARCHAR(64) NOT NULL UNIQUE,
product_type VARCHAR(32) NOT NULL, -- membership, credits, api
product_name VARCHAR(255) NOT NULL,
amount NUMERIC(10, 2) NOT NULL,
credits_amount INTEGER, -- 购买的积分数
status VARCHAR(32) NOT NULL DEFAULT 'pending', -- pending, paid, cancelled, refunded
payment_method VARCHAR(32), -- wechat, alipay, stripe
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS orders_user_id_idx ON orders (user_id);
CREATE INDEX IF NOT EXISTS orders_order_no_idx ON orders (order_no);
CREATE INDEX IF NOT EXISTS orders_status_idx ON orders (status);
CREATE INDEX IF NOT EXISTS orders_created_at_idx ON orders (created_at);
-- ============================================================
-- 5. 生成任务队列表 (generation_jobs)
-- ============================================================
CREATE TABLE IF NOT EXISTS generation_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(16) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'queued',
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
result JSONB,
error TEXT,
user_id UUID,
provider VARCHAR(128),
model_name VARCHAR(255),
api_url TEXT,
progress JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS generation_jobs_status_created_idx ON generation_jobs (status, created_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_status_updated_idx ON generation_jobs (status, updated_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_running_timeout_idx ON generation_jobs (updated_at) WHERE status = 'running';
CREATE INDEX IF NOT EXISTS generation_jobs_created_idx ON generation_jobs (created_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_user_created_idx ON generation_jobs (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_provider_model_created_idx ON generation_jobs (type, provider, model_name, created_at DESC);
-- ============================================================
-- 6. 用户自定义 API 密钥表 (user_api_keys)
-- ============================================================
CREATE TABLE IF NOT EXISTS user_api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID DEFAULT auth.uid(),
provider VARCHAR(64) NOT NULL, -- openai, stabilityai, runway, etc.
api_url TEXT, -- 完整 API 端点 URL
model_name VARCHAR(128), -- 具体模型名称
api_key_encrypted TEXT NOT NULL, -- 加密存储的 API Key
api_key_preview VARCHAR(20), -- Key 尾号 (如 sk-...4f3e)
supplier_name VARCHAR(128),
note TEXT NOT NULL DEFAULT '',
type VARCHAR(16) NOT NULL DEFAULT 'image',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS user_api_keys_user_id_idx ON user_api_keys (user_id);
CREATE INDEX IF NOT EXISTS user_api_keys_provider_idx ON user_api_keys (provider);
-- ============================================================
-- 7. 作品点赞表 (work_likes)
-- ============================================================
CREATE TABLE IF NOT EXISTS work_likes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID DEFAULT auth.uid(),
work_id UUID NOT NULL REFERENCES works(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS work_likes_user_id_idx ON work_likes (user_id);
CREATE INDEX IF NOT EXISTS work_likes_work_id_idx ON work_likes (work_id);
-- 唯一约束:每个用户对每个作品只能点赞一次
CREATE UNIQUE INDEX IF NOT EXISTS work_likes_user_work_uniq ON work_likes (user_id, work_id);
-- ============================================================
-- 8. 网站配置表 (site_config)
-- ============================================================
CREATE TABLE IF NOT EXISTS site_config (
id INTEGER PRIMARY KEY DEFAULT 1,
site_name VARCHAR(128) NOT NULL DEFAULT '妙境',
site_tab_title VARCHAR(255) NOT NULL DEFAULT '妙境 - AI创作平台',
site_description TEXT NOT NULL DEFAULT '',
site_keywords TEXT NOT NULL DEFAULT '',
logo_url TEXT,
favicon_url TEXT,
announcement TEXT NOT NULL DEFAULT '',
membership_enabled BOOLEAN NOT NULL DEFAULT TRUE,
terms_of_service TEXT NOT NULL DEFAULT '',
privacy_policy TEXT NOT NULL DEFAULT '',
about_us TEXT NOT NULL DEFAULT '',
help_center TEXT NOT NULL DEFAULT '',
filing_info TEXT NOT NULL DEFAULT '',
filing_url TEXT NOT NULL DEFAULT '',
public_security_filing_info TEXT NOT NULL DEFAULT '',
public_security_filing_url TEXT NOT NULL DEFAULT '',
log_retention_days INTEGER NOT NULL DEFAULT 30,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
-- 插入默认配置
INSERT INTO site_config (id, site_name, site_tab_title)
VALUES (1, '妙境', '妙境 - AI创作平台')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 9. 公告表 (announcements)
-- ============================================================
CREATE TABLE IF NOT EXISTS announcements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL, -- 支持 Markdown
type VARCHAR(32) NOT NULL DEFAULT 'site',
is_active BOOLEAN NOT NULL DEFAULT true,
starts_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS announcements_is_active_idx ON announcements (is_active);
CREATE INDEX IF NOT EXISTS announcements_expires_at_idx ON announcements (expires_at);
-- ============================================================
-- 10. 网站统计表 (site_stats)
-- ============================================================
CREATE TABLE IF NOT EXISTS site_stats (
id INTEGER PRIMARY KEY DEFAULT 1,
total_visits BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO site_stats (id, total_visits) VALUES (1, 0) ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 11. 平台日志
-- ============================================================
CREATE TABLE IF NOT EXISTS platform_log_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
retention_days INTEGER NOT NULL DEFAULT 30,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO platform_log_settings (id, retention_days)
VALUES (1, 30)
ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS platform_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(32) NOT NULL,
level VARCHAR(16) NOT NULL DEFAULT 'info',
action VARCHAR(128) NOT NULL,
message TEXT NOT NULL,
user_id UUID,
user_name VARCHAR(255),
user_email VARCHAR(255),
target_type VARCHAR(64),
target_id VARCHAR(255),
ip_address VARCHAR(64),
user_agent TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS platform_logs_type_created_idx ON platform_logs (type, created_at DESC);
CREATE INDEX IF NOT EXISTS platform_logs_level_created_idx ON platform_logs (level, created_at DESC);
CREATE INDEX IF NOT EXISTS platform_logs_user_created_idx ON platform_logs (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS platform_logs_created_idx ON platform_logs (created_at DESC);
-- ============================================================
-- 12. API 供应商与推荐模型配置
-- ============================================================
CREATE TABLE IF NOT EXISTS api_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(128) NOT NULL UNIQUE,
default_api_url TEXT,
default_model VARCHAR(255),
type VARCHAR(16) NOT NULL DEFAULT 'image',
website TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS model_recommendations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
model_name VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
type VARCHAR(16) NOT NULL DEFAULT 'image',
provider_id UUID REFERENCES api_providers(id) ON DELETE SET NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS api_providers_active_sort_idx ON api_providers (is_active, sort_order);
CREATE INDEX IF NOT EXISTS model_recommendations_active_type_sort_idx ON model_recommendations (is_active, type, sort_order);
CREATE INDEX IF NOT EXISTS model_recommendations_provider_idx ON model_recommendations (provider_id);
INSERT INTO api_providers (name, default_api_url, default_model, type, website, is_active, sort_order)
VALUES
('硅基流动', 'https://api.siliconflow.cn/v1/images/generations', 'black-forest-labs/FLUX.1-schnell', 'image', 'https://cloud.siliconflow.cn', true, 10),
('mozheAPI', 'https://openai.mozhevip.top', '', 'image', 'https://openai.mozhevip.top', true, 20),
('OpenAI', 'https://api.openai.com/v1/images/generations', 'dall-e-3', 'image', NULL, true, 30),
('Stability AI', 'https://api.stability.ai/v1/generation/stable-diffusion-xl/text-to-image', 'stable-diffusion-xl', 'image', NULL, true, 40),
('Midjourney', '', 'midjourney-v6', 'image', NULL, true, 50),
('Runway', 'https://api.runwayml.com/v1/image_to_video', 'gen-3-alpha', 'video', NULL, true, 60),
('Pika', '', 'pika-1.0', 'video', NULL, true, 70),
('Kling', '', 'kling-v1', 'video', NULL, true, 80),
('DeepSeek', 'https://api.deepseek.com/v1/chat/completions', 'deepseek-chat', 'text', NULL, true, 90),
('OpenAI GPT', 'https://api.openai.com/v1/chat/completions', 'gpt-4o', 'text', NULL, true, 100),
('自定义', '', '', 'image', NULL, true, 999)
ON CONFLICT (name) DO NOTHING;
INSERT INTO model_recommendations (model_name, display_name, type, provider_id, is_active, sort_order)
SELECT 'gpt-image-2', 'gpt-image-2', 'image', NULL, true, 10
WHERE NOT EXISTS (
SELECT 1 FROM model_recommendations
WHERE model_name = 'gpt-image-2' AND type = 'image' AND provider_id IS NULL
);
-- ============================================================
-- 兼容旧版本库结构的幂等补丁
-- ============================================================
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS email_bound_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS email_sender_domain VARCHAR(255),
ADD COLUMN IF NOT EXISTS preferred_theme VARCHAR(16) NOT NULL DEFAULT 'dark';
UPDATE profiles
SET preferred_theme = 'dark'
WHERE preferred_theme IS NULL
OR preferred_theme NOT IN ('dark', 'light');
ALTER TABLE works
ADD COLUMN IF NOT EXISTS views_count INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ;
ALTER TABLE user_api_keys
ADD COLUMN IF NOT EXISTS supplier_name VARCHAR(128),
ADD COLUMN IF NOT EXISTS note TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS type VARCHAR(16) NOT NULL DEFAULT 'image';
ALTER TABLE site_config
ADD COLUMN IF NOT EXISTS site_description TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS site_keywords TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS announcement TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS membership_enabled BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS terms_of_service TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS privacy_policy TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS about_us TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS help_center TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS filing_info TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS filing_url TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS public_security_filing_info TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS public_security_filing_url TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS log_retention_days INTEGER NOT NULL DEFAULT 30;
ALTER TABLE generation_jobs
ADD COLUMN IF NOT EXISTS user_id UUID,
ADD COLUMN IF NOT EXISTS provider VARCHAR(128),
ADD COLUMN IF NOT EXISTS model_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS api_url TEXT,
ADD COLUMN IF NOT EXISTS progress JSONB NOT NULL DEFAULT '{}'::jsonb;
CREATE INDEX IF NOT EXISTS generation_jobs_user_created_idx ON generation_jobs (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS generation_jobs_provider_model_created_idx ON generation_jobs (type, provider, model_name, created_at DESC);
CREATE TABLE IF NOT EXISTS system_api_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider VARCHAR(128),
name VARCHAR(255) NOT NULL,
api_url TEXT NOT NULL DEFAULT '',
model_name VARCHAR(255) NOT NULL,
note TEXT NOT NULL DEFAULT '',
api_key_encrypted TEXT NOT NULL DEFAULT '',
api_key_preview VARCHAR(64) NOT NULL DEFAULT '',
type VARCHAR(16) NOT NULL DEFAULT 'image',
credits_per_use INTEGER NOT NULL DEFAULT 10,
is_active BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS system_api_configs_active_type_sort_idx ON system_api_configs (is_active, type, sort_order);
CREATE TABLE IF NOT EXISTS payment_methods (
id VARCHAR(64) PRIMARY KEY,
type VARCHAR(32) NOT NULL,
name VARCHAR(128) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT FALSE,
public_config JSONB NOT NULL DEFAULT '{}'::jsonb,
secret_config_encrypted JSONB NOT NULL DEFAULT '{}'::jsonb,
secret_config_preview JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
INSERT INTO payment_methods (id, type, name, is_active) VALUES
('pm-alipay', 'alipay', '支付宝', true),
('pm-wechat', 'wechat', '微信支付', false),
('pm-manual', 'manual', '手动转账', false),
('pm-stripe', 'stripe', 'Stripe', false)
ON CONFLICT (id) DO NOTHING;
CREATE INDEX IF NOT EXISTS platform_logs_user_name_idx ON platform_logs (LOWER(COALESCE(user_name, '')));
CREATE INDEX IF NOT EXISTS platform_logs_user_email_idx ON platform_logs (LOWER(COALESCE(user_email, '')));
ALTER TABLE announcements
ADD COLUMN IF NOT EXISTS type VARCHAR(32) NOT NULL DEFAULT 'site';
ALTER TABLE platform_log_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE platform_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE api_providers ENABLE ROW LEVEL SECURITY;
ALTER TABLE model_recommendations ENABLE ROW LEVEL SECURITY;
ALTER TABLE system_api_configs ENABLE ROW LEVEL SECURITY;
ALTER TABLE payment_methods ENABLE ROW LEVEL SECURITY;
-- ============================================================
-- Row Level Security (RLS) 策略
-- ============================================================
-- 启用所有表的 RLS
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE works ENABLE ROW LEVEL SECURITY;
ALTER TABLE credit_transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_api_keys ENABLE ROW LEVEL SECURITY;
ALTER TABLE work_likes ENABLE ROW LEVEL SECURITY;
ALTER TABLE site_config ENABLE ROW LEVEL SECURITY;
ALTER TABLE announcements ENABLE ROW LEVEL SECURITY;
ALTER TABLE site_stats ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "profiles_read_own" ON profiles;
DROP POLICY IF EXISTS "profiles_update_own" ON profiles;
DROP POLICY IF EXISTS "profiles_admin_all" ON profiles;
DROP POLICY IF EXISTS "works_read_public" ON works;
DROP POLICY IF EXISTS "works_insert_own" ON works;
DROP POLICY IF EXISTS "works_update_own" ON works;
DROP POLICY IF EXISTS "works_delete_own" ON works;
DROP POLICY IF EXISTS "works_admin_all" ON works;
DROP POLICY IF EXISTS "credit_transactions_read_own" ON credit_transactions;
DROP POLICY IF EXISTS "credit_transactions_admin_all" ON credit_transactions;
DROP POLICY IF EXISTS "orders_read_own" ON orders;
DROP POLICY IF EXISTS "orders_insert_own" ON orders;
DROP POLICY IF EXISTS "orders_admin_all" ON orders;
DROP POLICY IF EXISTS "user_api_keys_read_own" ON user_api_keys;
DROP POLICY IF EXISTS "user_api_keys_insert_own" ON user_api_keys;
DROP POLICY IF EXISTS "user_api_keys_update_own" ON user_api_keys;
DROP POLICY IF EXISTS "user_api_keys_delete_own" ON user_api_keys;
DROP POLICY IF EXISTS "work_likes_read_all" ON work_likes;
DROP POLICY IF EXISTS "work_likes_insert_own" ON work_likes;
DROP POLICY IF EXISTS "work_likes_delete_own" ON work_likes;
DROP POLICY IF EXISTS "site_config_read_all" ON site_config;
DROP POLICY IF EXISTS "site_config_write_auth" ON site_config;
DROP POLICY IF EXISTS "site_config_admin_write" ON site_config;
DROP POLICY IF EXISTS "announcements_read_all" ON announcements;
DROP POLICY IF EXISTS "announcements_write_auth" ON announcements;
DROP POLICY IF EXISTS "announcements_admin_write" ON announcements;
DROP POLICY IF EXISTS "site_stats_read_all" ON site_stats;
DROP POLICY IF EXISTS "site_stats_write_auth" ON site_stats;
-- profiles: 用户可读自己的资料,管理员可读写所有
CREATE POLICY "profiles_read_own" ON profiles FOR SELECT USING (auth.uid() = id);
CREATE POLICY "profiles_update_own" ON profiles FOR UPDATE USING (auth.uid() = id);
CREATE POLICY "profiles_admin_all" ON profiles FOR ALL USING (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
-- works: 用户可管理自己的作品,公开作品所有人可读
CREATE POLICY "works_read_public" ON works FOR SELECT USING (is_public = true OR auth.uid() = user_id);
CREATE POLICY "works_insert_own" ON works FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "works_update_own" ON works FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "works_delete_own" ON works FOR DELETE USING (auth.uid() = user_id);
CREATE POLICY "works_admin_all" ON works FOR ALL USING (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
-- credit_transactions: 用户可读自己的记录
CREATE POLICY "credit_transactions_read_own" ON credit_transactions FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "credit_transactions_admin_all" ON credit_transactions FOR ALL USING (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
-- orders: 用户可读自己的订单
CREATE POLICY "orders_read_own" ON orders FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "orders_insert_own" ON orders FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "orders_admin_all" ON orders FOR ALL USING (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
-- user_api_keys: 用户可管理自己的密钥
CREATE POLICY "user_api_keys_read_own" ON user_api_keys FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "user_api_keys_insert_own" ON user_api_keys FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "user_api_keys_update_own" ON user_api_keys FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "user_api_keys_delete_own" ON user_api_keys FOR DELETE USING (auth.uid() = user_id);
-- work_likes: 认证用户可点赞,所有人可读
CREATE POLICY "work_likes_read_all" ON work_likes FOR SELECT USING (true);
CREATE POLICY "work_likes_insert_own" ON work_likes FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "work_likes_delete_own" ON work_likes FOR DELETE USING (auth.uid() = user_id);
-- site_config: 所有人可读,认证用户可写 (管理员操作通过 service role key)
CREATE POLICY "site_config_read_all" ON site_config FOR SELECT USING (true);
CREATE POLICY "site_config_admin_write" ON site_config FOR ALL USING (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
-- announcements: 所有人可读,认证用户可写 (管理员操作)
CREATE POLICY "announcements_read_all" ON announcements FOR SELECT USING (true);
CREATE POLICY "announcements_admin_write" ON announcements FOR ALL USING (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);
-- site_stats: 公开读,访问量递增走 SECURITY DEFINER 函数
CREATE POLICY "site_stats_read_all" ON site_stats FOR SELECT USING (true);
-- ============================================================
-- Supabase Storage 桶 (通过 Supabase Dashboard 或 API 创建)
-- ============================================================
-- 需要在 Supabase Dashboard 中手动创建以下 Storage 桶:
-- 1. site-assets (公开读) — 存放网站 Logo、Favicon
-- 2. works (私有) — 存放用户生成的图片/视频文件
--
-- 或者通过 SQL (需要 service_role 权限):
-- INSERT INTO storage.buckets (id, name, public) VALUES ('site-assets', 'site-assets', true) ON CONFLICT DO NOTHING;
-- INSERT INTO storage.buckets (id, name, public) VALUES ('works', 'works', false) ON CONFLICT DO NOTHING;
-- ============================================================
-- 触发器: 自动更新 updated_at 字段
-- ============================================================
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS profiles_updated_at ON profiles;
DROP TRIGGER IF EXISTS works_updated_at ON works;
DROP TRIGGER IF EXISTS orders_updated_at ON orders;
DROP TRIGGER IF EXISTS user_api_keys_updated_at ON user_api_keys;
DROP TRIGGER IF EXISTS site_config_updated_at ON site_config;
DROP TRIGGER IF EXISTS announcements_updated_at ON announcements;
CREATE TRIGGER profiles_updated_at BEFORE UPDATE ON profiles FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER works_updated_at BEFORE UPDATE ON works FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER orders_updated_at BEFORE UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER user_api_keys_updated_at BEFORE UPDATE ON user_api_keys FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER site_config_updated_at BEFORE UPDATE ON site_config FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER announcements_updated_at BEFORE UPDATE ON announcements FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- ============================================================
-- 触发器: 新用户注册时自动创建 profile
-- (仅在使用 Supabase Auth 时生效)
-- ============================================================
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO profiles (id, email, nickname, role, membership_tier, credits_balance, daily_quota_limit)
VALUES (
NEW.id,
NEW.email,
COALESCE(NEW.raw_user_meta_data->>'nickname', split_part(NEW.email, '@', 1)),
'user',
'free',
10, -- 新用户赠送 10 积分
5 -- 每日配额 5 次
)
ON CONFLICT (id) DO NOTHING;
-- 记录注册赠送积分
INSERT INTO credit_transactions (user_id, amount, balance_after, type, description)
SELECT NEW.id, 10, 10, 'gift', '新用户注册奖励'
WHERE NOT EXISTS (
SELECT 1 FROM credit_transactions
WHERE user_id = NEW.id AND type = 'gift' AND description = '新用户注册奖励'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
-- ============================================================
-- 初始化管理员账户 (可选)
-- 请在注册管理员后,手动执行以下 SQL 将角色设为 admin:
-- UPDATE profiles SET role = 'admin' WHERE email = 'your-admin@example.com';
-- ============================================================
-- ============================================================
-- 原子递增访问量的 SQL 函数
-- ============================================================
CREATE OR REPLACE FUNCTION increment_visits()
RETURNS BIGINT AS $$
DECLARE
new_count BIGINT;
BEGIN
UPDATE site_stats SET total_visits = total_visits + 1, updated_at = now() WHERE id = 1
RETURNING total_visits INTO new_count;
RETURN new_count;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- 完成
SELECT 'Database initialization completed successfully!' AS status;

12
scripts/prepare.sh Normal file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
set -Eeuo pipefail
COZE_WORKSPACE_PATH="${COZE_WORKSPACE_PATH:-$(pwd)}"
cd "${COZE_WORKSPACE_PATH}"
echo "Installing dependencies..."
pnpm install --prefer-frozen-lockfile --prefer-offline --loglevel debug --reporter=append-only
if command -v coze > /dev/null 2>&1 && coze check-bins --help > /dev/null 2>&1; then
coze check-bins --fix
fi

46
scripts/start.sh Normal file
View File

@@ -0,0 +1,46 @@
#!/bin/bash
set -Eeuo pipefail
COZE_WORKSPACE_PATH="${COZE_WORKSPACE_PATH:-$(pwd)}"
# Load environment variables from .env.local if it exists. PM2 role-specific
# values are restored afterwards so backend/console services keep their ports.
PM2_DEPLOY_RUN_PORT="${DEPLOY_RUN_PORT:-}"
PM2_APP_RUNTIME_ROLE="${APP_RUNTIME_ROLE:-}"
PM2_BACKEND_INTERNAL_URL="${BACKEND_INTERNAL_URL:-}"
PM2_CONSOLE_INTERNAL_URL="${CONSOLE_INTERNAL_URL:-}"
if [ -f "${COZE_WORKSPACE_PATH}/.env.local" ]; then
set +u
set -a
# shellcheck disable=SC1091
source "${COZE_WORKSPACE_PATH}/.env.local"
set +a
set -u
fi
[ -n "${PM2_DEPLOY_RUN_PORT}" ] && DEPLOY_RUN_PORT="${PM2_DEPLOY_RUN_PORT}"
[ -n "${PM2_APP_RUNTIME_ROLE}" ] && APP_RUNTIME_ROLE="${PM2_APP_RUNTIME_ROLE}"
[ -n "${PM2_BACKEND_INTERNAL_URL}" ] && BACKEND_INTERNAL_URL="${PM2_BACKEND_INTERNAL_URL}"
[ -n "${PM2_CONSOLE_INTERNAL_URL}" ] && CONSOLE_INTERNAL_URL="${PM2_CONSOLE_INTERNAL_URL}"
if [ -n "${DEPLOY_NODE_BIN_DIR:-}" ] && [ -d "${DEPLOY_NODE_BIN_DIR}" ]; then
export PATH="${DEPLOY_NODE_BIN_DIR}:${PATH}"
fi
PORT=${1:-5000}
DEPLOY_RUN_PORT="${DEPLOY_RUN_PORT:-$PORT}"
APP_RUNTIME_ROLE="${APP_RUNTIME_ROLE:-full}"
start_service() {
cd "${COZE_WORKSPACE_PATH}"
echo "Starting ${APP_RUNTIME_ROLE} HTTP service on port ${DEPLOY_RUN_PORT} for deploy..."
echo "COZE_PROJECT_ENV: ${COZE_PROJECT_ENV}"
export NODE_ENV="${NODE_ENV:-production}"
export APP_RUNTIME_ROLE
PORT=${DEPLOY_RUN_PORT} node dist/server.js
}
echo "Starting ${APP_RUNTIME_ROLE} HTTP service on port ${DEPLOY_RUN_PORT} for deploy..."
start_service