diff --git a/docs/issue-2-release-verification.md b/docs/issue-2-release-verification.md new file mode 100644 index 0000000..00ab984 --- /dev/null +++ b/docs/issue-2-release-verification.md @@ -0,0 +1,28 @@ +# Issue #2 SaaS release verification + +Date: 2026-09-05 +Branch: `feat/saas-tenants-subscriptions-routing` +Source: delivery packages `shortlink-saas-delivery-20260905.zip` / `shortlink-saas-complete.zip` + +## Gate results + +```text +gofmt -l $(find . -name '*.go' -not -path './vendor/*') # clean +go test ./... # pass +go vet ./... # pass +go build ./... # pass +node --check web/static/*.js # pass +git diff --check # pass +``` + +## Compatibility fixes applied during integration + +- Legacy `CreateShortLink` / `CreateLiveQR` now assign the owner's personal tenant and use `tenant_pending`. +- `EnsurePersonalTenant` backfills orphaned `tenant_id` rows for the account. +- Authorization tests updated for tenant-scoped lists and intentional cross-tenant `404` responses. + +## Notes + +- Public short codes remain globally unique. +- Existing single-target links continue to work via `target_url` fallback. +- Platform admins remain separated from tenant roles; resource lists are workspace-scoped. diff --git a/internal/dbutil/migrations/mysql/006_saas_tenants_subscriptions_routing.sql b/internal/dbutil/migrations/mysql/006_saas_tenants_subscriptions_routing.sql new file mode 100644 index 0000000..757b0ac --- /dev/null +++ b/internal/dbutil/migrations/mysql/006_saas_tenants_subscriptions_routing.sql @@ -0,0 +1,195 @@ +CREATE TABLE IF NOT EXISTS tenants ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(128) NOT NULL, + name VARCHAR(255) NOT NULL, + kind VARCHAR(32) NOT NULL DEFAULT 'organization', + status VARCHAR(32) NOT NULL DEFAULT 'active', + owner_account_id BIGINT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenants_slug(slug), + KEY idx_tenants_owner(owner_account_id), + CONSTRAINT fk_tenants_owner FOREIGN KEY(owner_account_id) REFERENCES admin_accounts(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tenant_members ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + account_id BIGINT NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'member', + status VARCHAR(32) NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_members_account(tenant_id,account_id), + KEY idx_tenant_members_account_status(account_id,status), + CONSTRAINT fk_tenant_members_tenant FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + CONSTRAINT fk_tenant_members_account FOREIGN KEY(account_id) REFERENCES admin_accounts(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS plans ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + description TEXT NOT NULL, + price_monthly_cents BIGINT NOT NULL DEFAULT 0, + currency VARCHAR(16) NOT NULL DEFAULT 'CNY', + max_members BIGINT NOT NULL DEFAULT 0, + max_short_links BIGINT NOT NULL DEFAULT 0, + max_live_qrs BIGINT NOT NULL DEFAULT 0, + max_targets_per_link BIGINT NOT NULL DEFAULT 0, + monthly_visits BIGINT NOT NULL DEFAULT 0, + features_json LONGTEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_plans_code(code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tenant_subscriptions ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + plan_id BIGINT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + provider VARCHAR(64) NOT NULL DEFAULT 'manual', + external_customer_id VARCHAR(255) NULL, + external_subscription_id VARCHAR(255) NULL, + current_period_start DATETIME NULL, + current_period_end DATETIME NULL, + cancel_at_period_end TINYINT(1) NOT NULL DEFAULT 0, + trial_ends_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_subscriptions_tenant(tenant_id), + KEY idx_tenant_subscriptions_status(status), + CONSTRAINT fk_subscriptions_tenant FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + CONSTRAINT fk_subscriptions_plan FOREIGN KEY(plan_id) REFERENCES plans(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS subscription_change_requests ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + from_plan_id BIGINT NOT NULL, + to_plan_id BIGINT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + pending_guard VARCHAR(32) NULL, + note TEXT NOT NULL, + review_note TEXT NOT NULL, + requested_by BIGINT NOT NULL, + reviewed_by BIGINT NULL, + reviewed_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_subscription_request_pending(tenant_id,pending_guard), + KEY idx_subscription_requests_status(status,created_at), + CONSTRAINT fk_subscription_requests_tenant FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + CONSTRAINT fk_subscription_requests_from_plan FOREIGN KEY(from_plan_id) REFERENCES plans(id), + CONSTRAINT fk_subscription_requests_to_plan FOREIGN KEY(to_plan_id) REFERENCES plans(id), + CONSTRAINT fk_subscription_requests_requested_by FOREIGN KEY(requested_by) REFERENCES admin_accounts(id), + CONSTRAINT fk_subscription_requests_reviewed_by FOREIGN KEY(reviewed_by) REFERENCES admin_accounts(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tenant_usage_monthly ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + period_key VARCHAR(7) NOT NULL, + visits BIGINT NOT NULL DEFAULT 0, + short_links_created BIGINT NOT NULL DEFAULT 0, + live_qrs_created BIGINT NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_usage_period(tenant_id,period_key), + CONSTRAINT fk_tenant_usage_tenant FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS approval_events ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + resource_type VARCHAR(32) NOT NULL, + resource_id BIGINT NOT NULL, + content_version BIGINT NOT NULL, + stage VARCHAR(32) NOT NULL, + action VARCHAR(32) NOT NULL, + actor_account_id BIGINT NOT NULL, + note TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_approval_events_resource(resource_type,resource_id,content_version,created_at), + KEY idx_approval_events_tenant(tenant_id,created_at), + CONSTRAINT fk_approval_events_tenant FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + CONSTRAINT fk_approval_events_actor FOREIGN KEY(actor_account_id) REFERENCES admin_accounts(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS short_link_targets ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + short_link_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL DEFAULT '', + target_url TEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + weight INT NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 100, + starts_at DATETIME NULL, + expires_at DATETIME NULL, + max_hits BIGINT NOT NULL DEFAULT 0, + hit_count BIGINT NOT NULL DEFAULT 0, + health_status VARCHAR(32) NOT NULL DEFAULT 'unknown', + last_hit_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_short_targets_select(short_link_id,status,health_status,sort_order,id), + KEY idx_short_targets_tenant(tenant_id,short_link_id), + CONSTRAINT fk_short_targets_tenant FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + CONSTRAINT fk_short_targets_link FOREIGN KEY(short_link_id) REFERENCES short_links(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE short_links + ADD COLUMN tenant_id BIGINT NULL, + ADD COLUMN routing_strategy VARCHAR(32) NOT NULL DEFAULT 'single', + ADD COLUMN current_target_cursor BIGINT NOT NULL DEFAULT 0, + ADD COLUMN content_version BIGINT NOT NULL DEFAULT 1, + ADD COLUMN approved_version BIGINT NOT NULL DEFAULT 0, + ADD KEY idx_short_links_tenant_created(tenant_id,created_at), + ADD KEY idx_short_links_tenant_approval(tenant_id,approval_status,updated_at); +ALTER TABLE live_qrs + ADD COLUMN tenant_id BIGINT NULL, + ADD COLUMN content_version BIGINT NOT NULL DEFAULT 1, + ADD COLUMN approved_version BIGINT NOT NULL DEFAULT 0, + ADD KEY idx_live_qrs_tenant_created(tenant_id,created_at), + ADD KEY idx_live_qrs_tenant_approval(tenant_id,approval_status,updated_at); +ALTER TABLE live_qr_items + ADD COLUMN content_version BIGINT NOT NULL DEFAULT 1, + ADD COLUMN approved_version BIGINT NOT NULL DEFAULT 0; +ALTER TABLE visit_logs + ADD COLUMN tenant_id BIGINT NULL, + ADD COLUMN target_id BIGINT NULL, + ADD KEY idx_visit_tenant_time(tenant_id,created_at), + ADD KEY idx_visit_target_time(target_id,created_at); +ALTER TABLE audit_logs + ADD COLUMN tenant_id BIGINT NULL, + ADD KEY idx_audit_tenant_time(tenant_id,created_at); + +INSERT IGNORE INTO plans(code,name,description,price_monthly_cents,currency,max_members,max_short_links,max_live_qrs,max_targets_per_link,monthly_visits,features_json,status) VALUES +('free','Free','适合个人和试用',0,'CNY',3,100,20,3,10000,'{"analytics":true,"approval":true}','active'), +('pro','Pro','适合小团队运营',4900,'CNY',10,2000,300,20,500000,'{"analytics":true,"approval":true,"routing":true}','active'), +('business','Business','适合多成员业务团队',19900,'CNY',50,20000,3000,100,5000000,'{"analytics":true,"approval":true,"routing":true,"audit":true}','active'), +('enterprise','Enterprise','私有化与定制额度',0,'CNY',0,0,0,0,0,'{"analytics":true,"approval":true,"routing":true,"audit":true,"custom":true}','active'); + +INSERT IGNORE INTO tenants(slug,name,kind,status,owner_account_id) +SELECT CONCAT('personal-',id),CASE WHEN TRIM(name)='' THEN COALESCE(email,'Personal Workspace') ELSE CONCAT(name,' Workspace') END,'personal','active',id FROM admin_accounts; +INSERT IGNORE INTO tenant_members(tenant_id,account_id,role,status) +SELECT t.id,t.owner_account_id,'owner','active' FROM tenants t WHERE t.kind='personal' AND t.owner_account_id IS NOT NULL; +INSERT IGNORE INTO tenants(slug,name,kind,status,owner_account_id) +SELECT 'platform-default','Platform Workspace','organization','active',id FROM admin_accounts ORDER BY CASE WHEN role='admin' THEN 0 ELSE 1 END,id LIMIT 1; +INSERT IGNORE INTO tenant_members(tenant_id,account_id,role,status) +SELECT t.id,t.owner_account_id,'owner','active' FROM tenants t WHERE t.slug='platform-default' AND t.owner_account_id IS NOT NULL; +INSERT IGNORE INTO tenant_subscriptions(tenant_id,plan_id,status,provider,current_period_start) +SELECT t.id,p.id,'active','manual',CURRENT_TIMESTAMP FROM tenants t JOIN plans p ON p.code='free'; + +UPDATE short_links s JOIN tenants t ON t.owner_account_id=s.owner_account_id AND t.kind='personal' SET s.tenant_id=t.id WHERE s.tenant_id IS NULL AND s.owner_account_id IS NOT NULL; +UPDATE short_links s JOIN tenants t ON t.slug='platform-default' SET s.tenant_id=t.id WHERE s.tenant_id IS NULL; +UPDATE live_qrs l JOIN tenants t ON t.owner_account_id=l.owner_account_id AND t.kind='personal' SET l.tenant_id=t.id WHERE l.tenant_id IS NULL AND l.owner_account_id IS NOT NULL; +UPDATE live_qrs l JOIN tenants t ON t.slug='platform-default' SET l.tenant_id=t.id WHERE l.tenant_id IS NULL; +UPDATE short_links SET approved_version=CASE WHEN approval_status='approved' THEN content_version ELSE 0 END, + approval_status=CASE WHEN approval_status='pending' THEN 'tenant_pending' WHEN approval_status='rejected' THEN 'tenant_rejected' ELSE approval_status END; +UPDATE live_qrs SET approved_version=CASE WHEN approval_status='approved' THEN content_version ELSE 0 END, + approval_status=CASE WHEN approval_status='pending' THEN 'tenant_pending' WHEN approval_status='rejected' THEN 'tenant_rejected' ELSE approval_status END; +UPDATE live_qr_items SET approved_version=CASE WHEN approval_status='approved' THEN content_version ELSE 0 END, + approval_status=CASE WHEN approval_status='pending' THEN 'tenant_pending' WHEN approval_status='rejected' THEN 'tenant_rejected' ELSE approval_status END; diff --git a/internal/dbutil/migrations/sqlite/006_saas_tenants_subscriptions_routing.sql b/internal/dbutil/migrations/sqlite/006_saas_tenants_subscriptions_routing.sql new file mode 100644 index 0000000..ef4e91d --- /dev/null +++ b/internal/dbutil/migrations/sqlite/006_saas_tenants_subscriptions_routing.sql @@ -0,0 +1,198 @@ +CREATE TABLE IF NOT EXISTS tenants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'organization', + status TEXT NOT NULL DEFAULT 'active', + owner_account_id INTEGER NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(owner_account_id) REFERENCES admin_accounts(id) ON DELETE SET NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_tenants_slug ON tenants(slug); +CREATE INDEX IF NOT EXISTS idx_tenants_owner ON tenants(owner_account_id); + +CREATE TABLE IF NOT EXISTS tenant_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + account_id INTEGER NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + status TEXT NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY(account_id) REFERENCES admin_accounts(id) ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_tenant_members_account ON tenant_members(tenant_id, account_id); +CREATE INDEX IF NOT EXISTS idx_tenant_members_account_status ON tenant_members(account_id, status); + +CREATE TABLE IF NOT EXISTS plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + price_monthly_cents INTEGER NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'CNY', + max_members INTEGER NOT NULL DEFAULT 0, + max_short_links INTEGER NOT NULL DEFAULT 0, + max_live_qrs INTEGER NOT NULL DEFAULT 0, + max_targets_per_link INTEGER NOT NULL DEFAULT 0, + monthly_visits INTEGER NOT NULL DEFAULT 0, + features_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_plans_code ON plans(code); + +CREATE TABLE IF NOT EXISTS tenant_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + plan_id INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + provider TEXT NOT NULL DEFAULT 'manual', + external_customer_id TEXT NULL, + external_subscription_id TEXT NULL, + current_period_start DATETIME NULL, + current_period_end DATETIME NULL, + cancel_at_period_end INTEGER NOT NULL DEFAULT 0, + trial_ends_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY(plan_id) REFERENCES plans(id) +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_tenant_subscriptions_tenant ON tenant_subscriptions(tenant_id); +CREATE INDEX IF NOT EXISTS idx_tenant_subscriptions_status ON tenant_subscriptions(status); + +CREATE TABLE IF NOT EXISTS subscription_change_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + from_plan_id INTEGER NOT NULL, + to_plan_id INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + pending_guard TEXT NULL, + note TEXT NOT NULL DEFAULT '', + review_note TEXT NOT NULL DEFAULT '', + requested_by INTEGER NOT NULL, + reviewed_by INTEGER NULL, + reviewed_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY(from_plan_id) REFERENCES plans(id), + FOREIGN KEY(to_plan_id) REFERENCES plans(id), + FOREIGN KEY(requested_by) REFERENCES admin_accounts(id), + FOREIGN KEY(reviewed_by) REFERENCES admin_accounts(id) +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_subscription_request_pending ON subscription_change_requests(tenant_id, pending_guard); +CREATE INDEX IF NOT EXISTS idx_subscription_requests_status ON subscription_change_requests(status, created_at); + +CREATE TABLE IF NOT EXISTS tenant_usage_monthly ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + period_key TEXT NOT NULL, + visits INTEGER NOT NULL DEFAULT 0, + short_links_created INTEGER NOT NULL DEFAULT 0, + live_qrs_created INTEGER NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_tenant_usage_period ON tenant_usage_monthly(tenant_id, period_key); + +CREATE TABLE IF NOT EXISTS approval_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + resource_type TEXT NOT NULL, + resource_id INTEGER NOT NULL, + content_version INTEGER NOT NULL, + stage TEXT NOT NULL, + action TEXT NOT NULL, + actor_account_id INTEGER NOT NULL, + note TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY(actor_account_id) REFERENCES admin_accounts(id) +); +CREATE INDEX IF NOT EXISTS idx_approval_events_resource ON approval_events(resource_type, resource_id, content_version, created_at); +CREATE INDEX IF NOT EXISTS idx_approval_events_tenant ON approval_events(tenant_id, created_at); + +CREATE TABLE IF NOT EXISTS short_link_targets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + short_link_id INTEGER NOT NULL, + name TEXT NOT NULL DEFAULT '', + target_url TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + weight INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 100, + starts_at DATETIME NULL, + expires_at DATETIME NULL, + max_hits INTEGER NOT NULL DEFAULT 0, + hit_count INTEGER NOT NULL DEFAULT 0, + health_status TEXT NOT NULL DEFAULT 'unknown', + last_hit_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY(short_link_id) REFERENCES short_links(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_short_targets_select ON short_link_targets(short_link_id, status, health_status, sort_order, id); +CREATE INDEX IF NOT EXISTS idx_short_targets_tenant ON short_link_targets(tenant_id, short_link_id); + +ALTER TABLE short_links ADD COLUMN tenant_id INTEGER NULL; +ALTER TABLE short_links ADD COLUMN routing_strategy TEXT NOT NULL DEFAULT 'single'; +ALTER TABLE short_links ADD COLUMN current_target_cursor INTEGER NOT NULL DEFAULT 0; +ALTER TABLE short_links ADD COLUMN content_version INTEGER NOT NULL DEFAULT 1; +ALTER TABLE short_links ADD COLUMN approved_version INTEGER NOT NULL DEFAULT 0; +CREATE INDEX IF NOT EXISTS idx_short_links_tenant_created ON short_links(tenant_id, created_at); +CREATE INDEX IF NOT EXISTS idx_short_links_tenant_approval ON short_links(tenant_id, approval_status, updated_at); + +ALTER TABLE live_qrs ADD COLUMN tenant_id INTEGER NULL; +ALTER TABLE live_qrs ADD COLUMN content_version INTEGER NOT NULL DEFAULT 1; +ALTER TABLE live_qrs ADD COLUMN approved_version INTEGER NOT NULL DEFAULT 0; +CREATE INDEX IF NOT EXISTS idx_live_qrs_tenant_created ON live_qrs(tenant_id, created_at); +CREATE INDEX IF NOT EXISTS idx_live_qrs_tenant_approval ON live_qrs(tenant_id, approval_status, updated_at); + +ALTER TABLE live_qr_items ADD COLUMN content_version INTEGER NOT NULL DEFAULT 1; +ALTER TABLE live_qr_items ADD COLUMN approved_version INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE visit_logs ADD COLUMN tenant_id INTEGER NULL; +ALTER TABLE visit_logs ADD COLUMN target_id INTEGER NULL; +CREATE INDEX IF NOT EXISTS idx_visit_tenant_time ON visit_logs(tenant_id, created_at); +CREATE INDEX IF NOT EXISTS idx_visit_target_time ON visit_logs(target_id, created_at); + +ALTER TABLE audit_logs ADD COLUMN tenant_id INTEGER NULL; +CREATE INDEX IF NOT EXISTS idx_audit_tenant_time ON audit_logs(tenant_id, created_at); + +INSERT OR IGNORE INTO plans(code,name,description,price_monthly_cents,currency,max_members,max_short_links,max_live_qrs,max_targets_per_link,monthly_visits,features_json,status) +VALUES('free','Free','适合个人和试用',0,'CNY',3,100,20,3,10000,'{"analytics":true,"approval":true}','active'); +INSERT OR IGNORE INTO plans(code,name,description,price_monthly_cents,currency,max_members,max_short_links,max_live_qrs,max_targets_per_link,monthly_visits,features_json,status) +VALUES('pro','Pro','适合小团队运营',4900,'CNY',10,2000,300,20,500000,'{"analytics":true,"approval":true,"routing":true}','active'); +INSERT OR IGNORE INTO plans(code,name,description,price_monthly_cents,currency,max_members,max_short_links,max_live_qrs,max_targets_per_link,monthly_visits,features_json,status) +VALUES('business','Business','适合多成员业务团队',19900,'CNY',50,20000,3000,100,5000000,'{"analytics":true,"approval":true,"routing":true,"audit":true}','active'); +INSERT OR IGNORE INTO plans(code,name,description,price_monthly_cents,currency,max_members,max_short_links,max_live_qrs,max_targets_per_link,monthly_visits,features_json,status) +VALUES('enterprise','Enterprise','私有化与定制额度',0,'CNY',0,0,0,0,0,'{"analytics":true,"approval":true,"routing":true,"audit":true,"custom":true}','active'); + +INSERT OR IGNORE INTO tenants(slug,name,kind,status,owner_account_id) +SELECT 'personal-' || id, CASE WHEN TRIM(name)='' THEN COALESCE(email,'Personal Workspace') ELSE name || ' Workspace' END, 'personal', 'active', id +FROM admin_accounts; +INSERT OR IGNORE INTO tenant_members(tenant_id,account_id,role,status) +SELECT t.id,t.owner_account_id,'owner','active' FROM tenants t WHERE t.kind='personal' AND t.owner_account_id IS NOT NULL; +INSERT OR IGNORE INTO tenants(slug,name,kind,status,owner_account_id) +SELECT 'platform-default','Platform Workspace','organization','active',id FROM admin_accounts ORDER BY CASE WHEN role='admin' THEN 0 ELSE 1 END,id LIMIT 1; +INSERT OR IGNORE INTO tenant_members(tenant_id,account_id,role,status) +SELECT t.id,t.owner_account_id,'owner','active' FROM tenants t WHERE t.slug='platform-default' AND t.owner_account_id IS NOT NULL; +INSERT OR IGNORE INTO tenant_subscriptions(tenant_id,plan_id,status,provider,current_period_start) +SELECT t.id,p.id,'active','manual',CURRENT_TIMESTAMP FROM tenants t JOIN plans p ON p.code='free'; + +UPDATE short_links SET tenant_id=(SELECT t.id FROM tenants t WHERE t.owner_account_id=short_links.owner_account_id AND t.kind='personal' LIMIT 1) WHERE tenant_id IS NULL AND owner_account_id IS NOT NULL; +UPDATE short_links SET tenant_id=(SELECT id FROM tenants WHERE slug='platform-default' LIMIT 1) WHERE tenant_id IS NULL; +UPDATE live_qrs SET tenant_id=(SELECT t.id FROM tenants t WHERE t.owner_account_id=live_qrs.owner_account_id AND t.kind='personal' LIMIT 1) WHERE tenant_id IS NULL AND owner_account_id IS NOT NULL; +UPDATE live_qrs SET tenant_id=(SELECT id FROM tenants WHERE slug='platform-default' LIMIT 1) WHERE tenant_id IS NULL; +UPDATE short_links SET approved_version=CASE WHEN approval_status='approved' THEN content_version ELSE 0 END, + approval_status=CASE WHEN approval_status='pending' THEN 'tenant_pending' WHEN approval_status='rejected' THEN 'tenant_rejected' ELSE approval_status END; +UPDATE live_qrs SET approved_version=CASE WHEN approval_status='approved' THEN content_version ELSE 0 END, + approval_status=CASE WHEN approval_status='pending' THEN 'tenant_pending' WHEN approval_status='rejected' THEN 'tenant_rejected' ELSE approval_status END; +UPDATE live_qr_items SET approved_version=CASE WHEN approval_status='approved' THEN content_version ELSE 0 END, + approval_status=CASE WHEN approval_status='pending' THEN 'tenant_pending' WHEN approval_status='rejected' THEN 'tenant_rejected' ELSE approval_status END; diff --git a/internal/model/saas.go b/internal/model/saas.go new file mode 100644 index 0000000..b3e2169 --- /dev/null +++ b/internal/model/saas.go @@ -0,0 +1,173 @@ +package model + +import "time" + +type Tenant struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Kind string `json:"kind"` + Status string `json:"status"` + OwnerAccountID int64 `json:"owner_account_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type TenantMember struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + AccountID int64 `json:"account_id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Plan struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + PriceMonthlyCents int64 `json:"price_monthly_cents"` + Currency string `json:"currency"` + MaxMembers int64 `json:"max_members"` + MaxShortLinks int64 `json:"max_short_links"` + MaxLiveQRs int64 `json:"max_live_qrs"` + MaxTargetsPerLink int64 `json:"max_targets_per_link"` + MonthlyVisits int64 `json:"monthly_visits"` + FeaturesJSON string `json:"features_json"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type TenantSubscription struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + PlanID int64 `json:"plan_id"` + PlanCode string `json:"plan_code"` + PlanName string `json:"plan_name"` + Status string `json:"status"` + Provider string `json:"provider"` + ExternalCustomerID string `json:"external_customer_id"` + ExternalSubscriptionID string `json:"external_subscription_id"` + CurrentPeriodStart *time.Time `json:"current_period_start,omitempty"` + CurrentPeriodEnd *time.Time `json:"current_period_end,omitempty"` + CancelAtPeriodEnd bool `json:"cancel_at_period_end"` + TrialEndsAt *time.Time `json:"trial_ends_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SubscriptionChangeRequest struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + TenantName string `json:"tenant_name"` + FromPlanID int64 `json:"from_plan_id"` + FromPlan string `json:"from_plan"` + ToPlanID int64 `json:"to_plan_id"` + ToPlan string `json:"to_plan"` + Status string `json:"status"` + Note string `json:"note"` + ReviewNote string `json:"review_note"` + RequestedBy int64 `json:"requested_by"` + ReviewedBy int64 `json:"reviewed_by"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type TenantUsage struct { + TenantID int64 `json:"tenant_id"` + PeriodKey string `json:"period_key"` + Visits int64 `json:"visits"` + ShortLinksCreated int64 `json:"short_links_created"` + LiveQRsCreated int64 `json:"live_qrs_created"` +} + +type TenantQuotaSnapshot struct { + Plan Plan `json:"plan"` + Subscription TenantSubscription `json:"subscription"` + MembersUsed int64 `json:"members_used"` + ShortLinksUsed int64 `json:"short_links_used"` + LiveQRsUsed int64 `json:"live_qrs_used"` + MonthlyVisitsUsed int64 `json:"monthly_visits_used"` + PeriodKey string `json:"period_key"` +} + +type ApprovalEvent struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + ResourceType string `json:"resource_type"` + ResourceID int64 `json:"resource_id"` + ContentVersion int64 `json:"content_version"` + Stage string `json:"stage"` + Action string `json:"action"` + ActorAccountID int64 `json:"actor_account_id"` + Note string `json:"note"` + CreatedAt time.Time `json:"created_at"` +} + +type ApprovalQueueItem struct { + TenantID int64 `json:"tenant_id"` + TenantName string `json:"tenant_name"` + ResourceType string `json:"resource_type"` + ResourceID int64 `json:"resource_id"` + Code string `json:"code"` + Title string `json:"title"` + ApprovalStatus string `json:"approval_status"` + ContentVersion int64 `json:"content_version"` + OwnerAccountID int64 `json:"owner_account_id"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ShortLinkTarget struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + ShortLinkID int64 `json:"short_link_id"` + Name string `json:"name"` + TargetURL string `json:"target_url"` + Status string `json:"status"` + Weight int `json:"weight"` + SortOrder int `json:"sort_order"` + StartsAt *time.Time `json:"starts_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + MaxHits int64 `json:"max_hits"` + HitCount int64 `json:"hit_count"` + HealthStatus string `json:"health_status"` + LastHitAt *time.Time `json:"last_hit_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ShortLinkWorkspace struct { + ShortLink + TenantID int64 `json:"tenant_id"` + RoutingStrategy string `json:"routing_strategy"` + CurrentTargetCursor int64 `json:"current_target_cursor"` + ContentVersion int64 `json:"content_version"` + ApprovedVersion int64 `json:"approved_version"` + Targets []ShortLinkTarget `json:"targets,omitempty"` +} + +type LiveQRWorkspace struct { + LiveQR + TenantID int64 `json:"tenant_id"` + ContentVersion int64 `json:"content_version"` + ApprovedVersion int64 `json:"approved_version"` +} + +type ShortRouteDecision struct { + TenantID int64 `json:"tenant_id"` + TargetID *int64 `json:"target_id,omitempty"` + TargetURL string `json:"target_url"` + Strategy string `json:"strategy"` + Counted bool `json:"counted"` +} + +type TenantAccess struct { + Tenant Tenant `json:"tenant"` + Role string `json:"role"` +} diff --git a/internal/server/api_ext.go b/internal/server/api_ext.go index e13bd74..e9279b7 100644 --- a/internal/server/api_ext.go +++ b/internal/server/api_ext.go @@ -9,6 +9,9 @@ import ( func (s *Server) adminAPIExt(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/admin") + if s.adminAPISaaS(w, r, path) { + return + } switch { case path == "/overview" && r.Method == http.MethodGet: s.apiExtOverview(w, r) diff --git a/internal/server/authorization_test.go b/internal/server/authorization_test.go index 9d28a74..c3ad30e 100644 --- a/internal/server/authorization_test.go +++ b/internal/server/authorization_test.go @@ -226,16 +226,18 @@ func TestUserSeesOnlyOwnedContentAndAdminSeesAll(t *testing.T) { t.Fatalf("user live list ids = %v, want only %d", got, fx.ownerLive.ID) } + // Platform admins are still tenant-scoped for resource lists; they should not + // automatically see another tenant's content without switching workspace. rr = authzRequest(t, fx.handler, http.MethodGet, "/api/admin/short-links", "", fx.adminCookies) requireStatus(t, rr, http.StatusOK) - if got := listedIDs(t, rr); len(got) != 2 { - t.Fatalf("admin short list ids = %v, want both records", got) + if got := listedIDs(t, rr); len(got) != 0 { + t.Fatalf("admin personal workspace short list ids = %v, want none from other tenants", got) } rr = authzRequest(t, fx.handler, http.MethodGet, "/api/admin/live-qrs", "", fx.adminCookies) requireStatus(t, rr, http.StatusOK) - if got := listedIDs(t, rr); len(got) != 2 { - t.Fatalf("admin live list ids = %v, want both records", got) + if got := listedIDs(t, rr); len(got) != 0 { + t.Fatalf("admin personal workspace live list ids = %v, want none from other tenants", got) } } @@ -256,7 +258,8 @@ func TestUserCannotAccessOrMutateOtherUsersShortLinks(t *testing.T) { {http.MethodPost, other + "/review", `{"status":"approved"}`}, } { rr := authzRequest(t, fx.handler, tc.method, tc.path, tc.body, fx.ownerCookies) - requireStatus(t, rr, http.StatusForbidden) + // Cross-tenant resources intentionally return 404 to reduce existence leakage. + requireStatus(t, rr, http.StatusNotFound) } own := fmt.Sprintf("/api/admin/short-links/%d", fx.ownerShort.ID) @@ -264,7 +267,7 @@ func TestUserCannotAccessOrMutateOtherUsersShortLinks(t *testing.T) { requireStatus(t, rr, http.StatusOK) rr = authzRequest(t, fx.handler, http.MethodPut, other, update, fx.adminCookies) - requireStatus(t, rr, http.StatusOK) + requireStatus(t, rr, http.StatusNotFound) } func TestUserCannotAccessOrMutateOtherUsersLiveQRs(t *testing.T) { @@ -286,7 +289,7 @@ func TestUserCannotAccessOrMutateOtherUsersLiveQRs(t *testing.T) { {http.MethodPost, other + "/review", `{"status":"approved","include_items":true}`}, } { rr := authzRequest(t, fx.handler, tc.method, tc.path, tc.body, fx.ownerCookies) - requireStatus(t, rr, http.StatusForbidden) + requireStatus(t, rr, http.StatusNotFound) } own := fmt.Sprintf("/api/admin/live-qrs/%d", fx.ownerLive.ID) @@ -294,7 +297,7 @@ func TestUserCannotAccessOrMutateOtherUsersLiveQRs(t *testing.T) { requireStatus(t, rr, http.StatusOK) rr = authzRequest(t, fx.handler, http.MethodPut, other, update, fx.adminCookies) - requireStatus(t, rr, http.StatusOK) + requireStatus(t, rr, http.StatusNotFound) } func TestUserCannotAccessOrReviewOtherUsersLiveQRItems(t *testing.T) { @@ -313,17 +316,17 @@ func TestUserCannotAccessOrReviewOtherUsersLiveQRItems(t *testing.T) { {http.MethodPost, otherItem, `{"status":"approved"}`}, } { rr := authzRequest(t, fx.handler, tc.method, tc.path, tc.body, fx.ownerCookies) - requireStatus(t, rr, http.StatusForbidden) + requireStatus(t, rr, http.StatusNotFound) } rr := authzRequest(t, fx.handler, http.MethodPut, ownItem, update, fx.ownerCookies) requireStatus(t, rr, http.StatusOK) rr = authzRequest(t, fx.handler, http.MethodPost, ownItem, `{"status":"approved"}`, fx.ownerCookies) - requireStatus(t, rr, http.StatusForbidden) + requireStatus(t, rr, http.StatusOK) rr = authzRequest(t, fx.handler, http.MethodPost, otherItem, `{"status":"approved"}`, fx.adminCookies) - requireStatus(t, rr, http.StatusOK) + requireStatus(t, rr, http.StatusNotFound) } func TestUserCannotUseAdminOnlyAPIsOrMutateGlobalAdminEmail(t *testing.T) { diff --git a/internal/server/public.go b/internal/server/public.go index deb6693..219a7a8 100644 --- a/internal/server/public.go +++ b/internal/server/public.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/base64" "errors" - "fmt" "image" _ "image/gif" _ "image/jpeg" @@ -46,10 +45,13 @@ func (s *Server) redirectCode(w http.ResponseWriter, r *http.Request, code strin target := sl.TargetURL now := time.Now() blockedMsg := "" + tenantID, _ := s.store().ResourceTenantID(r.Context(), "short_link", sl.ID) + var targetID *int64 + strategy := "single" switch { case sl.ApprovalStatus != "approved": status = "not_approved" - blockedMsg = "该短链尚未通过管理员审核。" + blockedMsg = "该短链尚未完成租户初审和平台终审。" case sl.Status != "active": status = "disabled" blockedMsg = "该短链已停用。" @@ -63,23 +65,55 @@ func (s *Server) redirectCode(w http.ResponseWriter, r *http.Request, code strin status = "limit_reached" blockedMsg = "该短链访问次数已达到上限。" } + + if status == "ok" { + clientKey := s.auth.Hash(util.ClientIP(r, s.cfg.TrustProxy)) + decision, routeErr := s.store().SelectShortTargetForVisit(r.Context(), sl.ID, clientKey) + if routeErr == nil { + tenantID = decision.TenantID + targetID = decision.TargetID + target = decision.TargetURL + strategy = decision.Strategy + } else { + switch { + case errors.Is(routeErr, store.ErrNotPublished): + status = "not_approved" + blockedMsg = "该短链当前内容版本尚未完成两级审批。" + case errors.Is(routeErr, store.ErrVisitLimitReached): + status = "limit_reached" + blockedMsg = "该短链访问次数已达到上限。" + case errors.Is(routeErr, store.ErrSubscriptionInactive): + status = "subscription_inactive" + blockedMsg = "该工作空间的订阅当前不可用。" + case errors.Is(routeErr, store.ErrQuotaExceeded): + status = "tenant_quota_reached" + blockedMsg = "该工作空间本月访问额度已用完。" + default: + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + } + } if status != "ok" && status != "not_approved" && sl.FallbackURL != "" { target = sl.FallbackURL - status = status + ":fallback" + status += ":fallback" } - s.recordVisit(r, &model.VisitLog{ResourceType: "short_link", ResourceID: sl.ID, Code: sl.Code, EventType: "redirect", Status: status, TargetURL: target}) - if !strings.HasPrefix(status, "ok") && !strings.Contains(status, "fallback") { + s.recordVisitSaaS(r, &model.VisitLog{ResourceType: "short_link", ResourceID: sl.ID, Code: sl.Code, EventType: "redirect", Status: status, TargetURL: target}, tenantID, targetID) + if status != "ok" && !strings.Contains(status, ":fallback") { s.renderPublicError(w, r, http.StatusGone, "无法访问", blockedMsg) return } - if err := s.store().IncrementShortVisit(r.Context(), sl.ID); err != nil { - // Do not block redirect for non-critical stats errors. - fmt.Printf("increment visit failed: %v\n", err) - } redirectType := sl.RedirectType if redirectType == 0 { redirectType = http.StatusFound } + // A load-balanced link must not emit a permanent redirect: browsers and + // intermediary caches would pin one selected backend and bypass routing. + if strategy != "single" && (redirectType == http.StatusMovedPermanently || redirectType == http.StatusPermanentRedirect) { + redirectType = http.StatusFound + } + w.Header().Set("Cache-Control", "no-store, private") + w.Header().Set("Pragma", "no-cache") http.Redirect(w, r, target, redirectType) } @@ -98,9 +132,25 @@ func (s *Server) liveQRPublic(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal error", http.StatusInternalServerError) return } + tenantID, _ := s.store().ResourceTenantID(r.Context(), "live_qr", live.ID) if live.ApprovalStatus != "approved" { - s.recordVisit(r, &model.VisitLog{ResourceType: "live_qr", ResourceID: live.ID, Code: live.Code, EventType: "visit", Status: "not_approved"}) - s.renderPublicError(w, r, http.StatusGone, "活码未审核", "该活码尚未通过管理员审核,暂不能使用。") + s.recordVisitSaaS(r, &model.VisitLog{ResourceType: "live_qr", ResourceID: live.ID, Code: live.Code, EventType: "visit", Status: "not_approved"}, tenantID, nil) + s.renderPublicError(w, r, http.StatusGone, "活码未审核", "该活码尚未完成租户初审和平台终审,暂不能使用。") + return + } + if err := s.store().IncrementTenantVisitForLive(r.Context(), tenantID); err != nil { + status := "subscription_inactive" + message := "该工作空间的订阅当前不可用。" + if errors.Is(err, store.ErrQuotaExceeded) { + status = "tenant_quota_reached" + message = "该工作空间本月访问额度已用完。" + } + s.recordVisitSaaS(r, &model.VisitLog{ResourceType: "live_qr", ResourceID: live.ID, Code: live.Code, EventType: "visit", Status: status}, tenantID, nil) + if live.FallbackURL != "" { + http.Redirect(w, r, live.FallbackURL, http.StatusFound) + return + } + s.renderPublicError(w, r, http.StatusGone, "无法访问", message) return } @@ -118,7 +168,7 @@ func (s *Server) liveQRPublic(w http.ResponseWriter, r *http.Request) { if item == nil { status = "no_active_item" if live.FallbackURL != "" { - s.recordVisit(r, &model.VisitLog{ResourceType: "live_qr", ResourceID: live.ID, Code: live.Code, EventType: "visit", Status: "fallback", TargetURL: live.FallbackURL}) + s.recordVisitSaaS(r, &model.VisitLog{ResourceType: "live_qr", ResourceID: live.ID, Code: live.Code, EventType: "visit", Status: "fallback", TargetURL: live.FallbackURL}, tenantID, nil) http.Redirect(w, r, live.FallbackURL, http.StatusFound) return } @@ -126,7 +176,7 @@ func (s *Server) liveQRPublic(w http.ResponseWriter, r *http.Request) { itemID = &item.ID target = item.TargetURL } - s.recordVisit(r, &model.VisitLog{ResourceType: "live_qr", ResourceID: live.ID, ItemID: itemID, Code: live.Code, EventType: "visit", Status: status, TargetURL: target}) + s.recordVisitSaaS(r, &model.VisitLog{ResourceType: "live_qr", ResourceID: live.ID, ItemID: itemID, Code: live.Code, EventType: "visit", Status: status, TargetURL: target}, tenantID, nil) if item == nil { s.renderPublicError(w, r, http.StatusGone, "暂无可用二维码", "当前活码下的二维码均未开始、已过期、已停用或达到展示上限。") return @@ -141,6 +191,26 @@ func (s *Server) liveQRPublic(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) recordVisitSaaS(r *http.Request, v *model.VisitLog, tenantID int64, targetID *int64) { + ip := util.ClientIP(r, s.cfg.TrustProxy) + ua := r.UserAgent() + device, browser, osName := util.DetectClient(ua) + v.IP = ip + v.IPHash = s.auth.Hash(ip) + v.UserAgent = util.Truncate(ua, 1024) + v.Referer = util.Truncate(r.Referer(), 1000) + v.DeviceType = device + v.Browser = browser + v.OS = osName + if v.EventType == "" { + v.EventType = "visit" + } + if v.Status == "" { + v.Status = "ok" + } + _ = s.store().RecordVisitSaaS(r.Context(), v, tenantID, targetID) +} + func (s *Server) recordVisit(r *http.Request, v *model.VisitLog) { ip := util.ClientIP(r, s.cfg.TrustProxy) ua := r.UserAgent() diff --git a/internal/server/saas_api.go b/internal/server/saas_api.go new file mode 100644 index 0000000..fe6097c --- /dev/null +++ b/internal/server/saas_api.go @@ -0,0 +1,1118 @@ +package server + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "ai-shortlink/internal/model" + "ai-shortlink/internal/store" + "ai-shortlink/internal/util" +) + +type tenantRequestActor struct { + Actor *actorInfo + Tenant *model.Tenant + Member *model.TenantMember +} + +func (a *tenantRequestActor) role() string { + if a == nil || a.Member == nil { + return "" + } + return a.Member.Role +} + +func (a *tenantRequestActor) canWrite() bool { + switch a.role() { + case "owner", "admin", "member": + return true + default: + return false + } +} + +func (a *tenantRequestActor) canReview() bool { + switch a.role() { + case "owner", "admin", "reviewer": + return true + default: + return false + } +} + +func (a *tenantRequestActor) canManageMembers() bool { + return a.role() == "owner" || a.role() == "admin" +} + +func (s *Server) requireTenantActor(w http.ResponseWriter, r *http.Request) (*tenantRequestActor, bool) { + actor, err := s.currentActor(r.Context()) + if err != nil { + writeJSON(w, http.StatusUnauthorized, apiErr("unauthorized", "登录状态无效")) + return nil, false + } + raw := strings.TrimSpace(r.Header.Get("X-Tenant-ID")) + var tenant *model.Tenant + var member *model.TenantMember + if raw == "" { + var role string + tenant, role, err = s.store().EnsurePersonalTenant(r.Context(), actor.Account.ID) + if err == nil { + _, member, err = s.store().GetTenantAccess(r.Context(), tenant.ID, actor.Account.ID) + if member != nil && member.Role == "" { + member.Role = role + } + } + } else { + tenantID, parseErr := strconv.ParseInt(raw, 10, 64) + if parseErr != nil || tenantID <= 0 { + writeJSON(w, http.StatusBadRequest, apiErr("bad_tenant", "X-Tenant-ID 不正确")) + return nil, false + } + tenant, member, err = s.store().GetTenantAccess(r.Context(), tenantID, actor.Account.ID) + } + if err != nil { + if errors.Is(err, store.ErrTenantForbidden) || errors.Is(err, store.ErrNotFound) { + writeJSON(w, http.StatusForbidden, apiErr("tenant_forbidden", "无权访问该工作空间")) + } else { + writeJSON(w, http.StatusInternalServerError, apiErr("tenant", err.Error())) + } + return nil, false + } + if tenant.Status != "active" { + writeJSON(w, http.StatusForbidden, apiErr("tenant_disabled", "工作空间已停用")) + return nil, false + } + return &tenantRequestActor{Actor: actor, Tenant: tenant, Member: member}, true +} + +func (s *Server) requirePlatformActor(w http.ResponseWriter, r *http.Request) (*actorInfo, bool) { + actor, err := s.currentActor(r.Context()) + if err != nil { + writeJSON(w, http.StatusUnauthorized, apiErr("unauthorized", "登录状态无效")) + return nil, false + } + if !actor.IsAdmin() { + writeJSON(w, http.StatusForbidden, apiErr("platform_admin_required", "仅平台总管理员可执行此操作")) + return nil, false + } + return actor, true +} + +func writeSaaSError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, store.ErrNotFound): + writeJSON(w, http.StatusNotFound, apiErr("not_found", "资源不存在")) + case errors.Is(err, store.ErrTenantForbidden): + writeJSON(w, http.StatusForbidden, apiErr("tenant_forbidden", "无权访问该工作空间资源")) + case errors.Is(err, store.ErrSubscriptionInactive): + writeJSON(w, http.StatusPaymentRequired, apiErr("subscription_inactive", "当前订阅不可用,请联系平台管理员")) + case errors.Is(err, store.ErrQuotaExceeded): + writeJSON(w, http.StatusConflict, apiErr("quota_exceeded", err.Error())) + case errors.Is(err, store.ErrApprovalState): + writeJSON(w, http.StatusConflict, apiErr("approval_state", err.Error())) + default: + writeJSON(w, http.StatusInternalServerError, apiErr("internal", err.Error())) + } +} + +func (s *Server) adminAPISaaS(w http.ResponseWriter, r *http.Request, path string) bool { + switch { + case strings.HasPrefix(path, "/saas"): + s.handleSaaSManagement(w, r, path) + return true + case path == "/short-links" || path == "/short-links/" || strings.HasPrefix(path, "/short-links/"): + s.handleTenantShortLinks(w, r, path) + return true + case path == "/live-qrs" || path == "/live-qrs/" || strings.HasPrefix(path, "/live-qrs/"): + s.handleTenantLiveQRs(w, r, path) + return true + case strings.HasPrefix(path, "/live-qr-items/"): + s.handleTenantLiveQRItem(w, r, path) + return true + default: + return false + } +} + +func (s *Server) handleSaaSManagement(w http.ResponseWriter, r *http.Request, path string) { + if strings.HasPrefix(path, "/saas/platform") { + s.handlePlatformManagement(w, r, path) + return + } + ta, ok := s.requireTenantActor(w, r) + if !ok { + return + } + switch { + case path == "/saas/bootstrap" && r.Method == http.MethodGet: + access, err := s.store().ListTenantAccessForAccount(r.Context(), ta.Actor.Account.ID) + if err != nil { + writeSaaSError(w, err) + return + } + quota, err := s.store().GetTenantQuotaSnapshot(r.Context(), ta.Tenant.ID) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "tenants": access, "current_tenant": ta.Tenant, "tenant_role": ta.role(), "quota": quota, "platform_admin": ta.Actor.IsAdmin()}) + case (path == "/saas/tenants" || path == "/saas/tenants/") && r.Method == http.MethodGet: + access, err := s.store().ListTenantAccessForAccount(r.Context(), ta.Actor.Account.ID) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": access}) + case (path == "/saas/tenants" || path == "/saas/tenants/") && r.Method == http.MethodPost: + var p struct { + Name string `json:"name"` + Slug string `json:"slug"` + } + if !decodeBody(w, r, &p) { + return + } + p.Name = strings.TrimSpace(p.Name) + p.Slug = safeTenantSlug(p.Slug, p.Name) + if p.Name == "" { + writeJSON(w, http.StatusBadRequest, apiErr("bad_request", "请填写工作空间名称")) + return + } + tenant, err := s.store().CreateTenant(r.Context(), ta.Actor.Account.ID, p.Slug, p.Name) + if err != nil { + writeSaaSError(w, err) + return + } + _ = s.store().AuditTenant(r.Context(), deviceIDFromContext(r.Context()), tenant.ID, "tenant.create", "tenant", &tenant.ID, tenant.Slug, util.ClientIP(r, s.cfg.TrustProxy)) + writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": tenant}) + case strings.HasPrefix(path, "/saas/tenants/"): + s.handleTenantSettings(w, r, ta, strings.TrimPrefix(path, "/saas/tenants/")) + case path == "/saas/plans" && r.Method == http.MethodGet: + plans, err := s.store().ListPlans(r.Context(), false) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": plans}) + case path == "/saas/subscription" && r.Method == http.MethodGet: + quota, err := s.store().GetTenantQuotaSnapshot(r.Context(), ta.Tenant.ID) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": quota}) + case path == "/saas/subscription/requests" && r.Method == http.MethodGet: + items, err := s.store().ListSubscriptionRequests(r.Context(), ta.Tenant.ID, false) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) + case path == "/saas/subscription/requests" && r.Method == http.MethodPost: + if ta.role() != "owner" && ta.role() != "admin" { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "仅工作空间所有者或管理员可以申请套餐变更")) + return + } + var p struct { + PlanID int64 `json:"plan_id"` + Note string `json:"note"` + } + if !decodeBody(w, r, &p) { + return + } + item, err := s.store().RequestPlanChange(r.Context(), ta.Tenant.ID, ta.Actor.Account.ID, p.PlanID, p.Note) + if err != nil { + writeSaaSError(w, err) + return + } + _ = s.store().AuditTenant(r.Context(), deviceIDFromContext(r.Context()), ta.Tenant.ID, "subscription.request", "subscription_change", &item.ID, item.ToPlan, util.ClientIP(r, s.cfg.TrustProxy)) + writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": item}) + case path == "/saas/reviews" && r.Method == http.MethodGet: + if !ta.canReview() { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "当前角色没有租户审批权限")) + return + } + items, err := s.store().ListApprovalQueue(r.Context(), ta.Tenant.ID, r.URL.Query().Get("stage"), false) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) + case strings.HasPrefix(path, "/saas/reviews/") && r.Method == http.MethodPost: + if !ta.canReview() { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "当前角色没有租户审批权限")) + return + } + parts := splitPath(strings.TrimPrefix(path, "/saas/reviews/")) + if len(parts) != 2 { + writeJSON(w, http.StatusBadRequest, apiErr("bad_path", "审批资源路径不正确")) + return + } + id, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || id <= 0 { + writeJSON(w, http.StatusBadRequest, apiErr("bad_id", "资源 ID 不正确")) + return + } + var p struct { + Action string `json:"action"` + Status string `json:"status"` + Note string `json:"note"` + IncludeItems bool `json:"include_items"` + } + if !decodeBody(w, r, &p) { + return + } + action := normalizeReviewAction(p.Action, p.Status) + if err := s.store().ReviewResourceTenant(r.Context(), ta.Tenant.ID, ta.Actor.Account.ID, parts[0], id, action, p.Note, p.IncludeItems); err != nil { + writeSaaSError(w, err) + return + } + _ = s.store().AuditTenant(r.Context(), deviceIDFromContext(r.Context()), ta.Tenant.ID, "approval.tenant."+action, parts[0], &id, p.Note, util.ClientIP(r, s.cfg.TrustProxy)) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + case path == "/saas/approval-events" && r.Method == http.MethodGet: + resourceType := strings.TrimSpace(r.URL.Query().Get("resource_type")) + resourceID, _ := strconv.ParseInt(r.URL.Query().Get("resource_id"), 10, 64) + items, err := s.store().ApprovalEventsByResource(r.Context(), ta.Tenant.ID, resourceType, resourceID) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) + default: + writeJSON(w, http.StatusNotFound, apiErr("not_found", "SaaS 接口不存在")) + } +} + +func (s *Server) handleTenantSettings(w http.ResponseWriter, r *http.Request, ta *tenantRequestActor, rest string) { + parts := splitPath(rest) + if len(parts) == 0 { + writeJSON(w, http.StatusNotFound, apiErr("not_found", "工作空间接口不存在")) + return + } + tenantID, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil || tenantID != ta.Tenant.ID { + writeJSON(w, http.StatusForbidden, apiErr("tenant_forbidden", "只能管理当前工作空间")) + return + } + if len(parts) == 1 && r.Method == http.MethodPut { + if ta.role() != "owner" && ta.role() != "admin" { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "当前角色不能修改工作空间")) + return + } + var p struct { + Name string `json:"name"` + Status string `json:"status"` + } + if !decodeBody(w, r, &p) { + return + } + if p.Status == "" { + p.Status = ta.Tenant.Status + } + item, err := s.store().UpdateTenant(r.Context(), tenantID, p.Name, p.Status) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": item}) + return + } + if len(parts) >= 2 && parts[1] == "members" { + if !ta.canManageMembers() { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "当前角色不能管理成员")) + return + } + if len(parts) == 2 && r.Method == http.MethodGet { + items, err := s.store().ListTenantMembers(r.Context(), tenantID) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) + return + } + var accountID int64 + var payload struct { + AccountID int64 `json:"account_id"` + Email string `json:"email"` + Role string `json:"role"` + Status string `json:"status"` + } + if r.Method == http.MethodPost || r.Method == http.MethodPut { + if !decodeBody(w, r, &payload) { + return + } + accountID = payload.AccountID + if len(parts) == 3 { + accountID, _ = strconv.ParseInt(parts[2], 10, 64) + } + if accountID <= 0 && strings.TrimSpace(payload.Email) != "" { + acct, err := s.store().FindAdminAccountByEmail(r.Context(), payload.Email) + if err != nil { + writeSaaSError(w, err) + return + } + accountID = acct.ID + } + if accountID <= 0 { + writeJSON(w, http.StatusBadRequest, apiErr("bad_account", "请填写已存在账户的邮箱或 ID")) + return + } + if payload.Role == "owner" && ta.role() != "owner" { + writeJSON(w, http.StatusForbidden, apiErr("owner_required", "只有所有者可以授予 owner 角色")) + return + } + item, err := s.store().UpsertTenantMember(r.Context(), tenantID, accountID, payload.Role, payload.Status) + if err != nil { + writeSaaSError(w, err) + return + } + _ = s.store().AuditTenant(r.Context(), deviceIDFromContext(r.Context()), tenantID, "tenant_member.upsert", "tenant_member", &item.ID, item.Role, util.ClientIP(r, s.cfg.TrustProxy)) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": item}) + return + } + if len(parts) == 3 && r.Method == http.MethodDelete { + accountID, _ = strconv.ParseInt(parts[2], 10, 64) + if accountID <= 0 { + writeJSON(w, http.StatusBadRequest, apiErr("bad_account", "账户 ID 不正确")) + return + } + if err := s.store().DeleteTenantMember(r.Context(), tenantID, accountID); err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } + } + writeJSON(w, http.StatusNotFound, apiErr("not_found", "工作空间接口不存在")) +} + +func (s *Server) handlePlatformManagement(w http.ResponseWriter, r *http.Request, path string) { + actor, ok := s.requirePlatformActor(w, r) + if !ok { + return + } + switch { + case path == "/saas/platform/reviews" && r.Method == http.MethodGet: + items, err := s.store().ListApprovalQueue(r.Context(), 0, r.URL.Query().Get("stage"), true) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) + case strings.HasPrefix(path, "/saas/platform/reviews/") && r.Method == http.MethodPost: + parts := splitPath(strings.TrimPrefix(path, "/saas/platform/reviews/")) + if len(parts) != 2 { + writeJSON(w, http.StatusBadRequest, apiErr("bad_path", "终审资源路径不正确")) + return + } + id, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || id <= 0 { + writeJSON(w, http.StatusBadRequest, apiErr("bad_id", "资源 ID 不正确")) + return + } + var p struct { + Action string `json:"action"` + Status string `json:"status"` + Note string `json:"note"` + IncludeItems bool `json:"include_items"` + } + if !decodeBody(w, r, &p) { + return + } + action := normalizeReviewAction(p.Action, p.Status) + if err := s.store().ReviewResourcePlatform(r.Context(), actor.Account.ID, parts[0], id, action, p.Note, p.IncludeItems); err != nil { + writeSaaSError(w, err) + return + } + tenantID, _ := s.store().ResourceTenantID(r.Context(), parts[0], id) + _ = s.store().AuditTenant(r.Context(), deviceIDFromContext(r.Context()), tenantID, "approval.platform."+action, parts[0], &id, p.Note, util.ClientIP(r, s.cfg.TrustProxy)) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + case path == "/saas/platform/subscription-requests" && r.Method == http.MethodGet: + items, err := s.store().ListSubscriptionRequests(r.Context(), 0, true) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) + case strings.HasPrefix(path, "/saas/platform/subscription-requests/") && r.Method == http.MethodPost: + parts := splitPath(strings.TrimPrefix(path, "/saas/platform/subscription-requests/")) + if len(parts) != 1 { + writeJSON(w, http.StatusBadRequest, apiErr("bad_path", "订阅申请路径不正确")) + return + } + id, _ := strconv.ParseInt(parts[0], 10, 64) + var p struct { + Action string `json:"action"` + Status string `json:"status"` + Note string `json:"note"` + } + if !decodeBody(w, r, &p) { + return + } + action := normalizeReviewAction(p.Action, p.Status) + item, err := s.store().ReviewSubscriptionRequest(r.Context(), id, actor.Account.ID, action, p.Note) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": item}) + case path == "/saas/platform/tenants" && r.Method == http.MethodGet: + limit, offset := pagination(r) + items, err := s.store().ListAllTenants(r.Context(), r.URL.Query().Get("q"), limit, offset) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) + case strings.HasPrefix(path, "/saas/platform/tenants/") && r.Method == http.MethodPut: + parts := splitPath(strings.TrimPrefix(path, "/saas/platform/tenants/")) + if len(parts) != 1 { + writeJSON(w, http.StatusBadRequest, apiErr("bad_path", "租户路径不正确")) + return + } + id, _ := strconv.ParseInt(parts[0], 10, 64) + var p struct { + Name string `json:"name"` + Status string `json:"status"` + } + if !decodeBody(w, r, &p) { + return + } + item, err := s.store().UpdateTenant(r.Context(), id, p.Name, p.Status) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": item}) + case path == "/saas/platform/plans" && r.Method == http.MethodGet: + items, err := s.store().ListPlans(r.Context(), true) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) + case strings.HasPrefix(path, "/saas/platform/plans/") && r.Method == http.MethodPut: + parts := splitPath(strings.TrimPrefix(path, "/saas/platform/plans/")) + if len(parts) != 1 { + writeJSON(w, http.StatusBadRequest, apiErr("bad_path", "套餐路径不正确")) + return + } + id, _ := strconv.ParseInt(parts[0], 10, 64) + var p model.Plan + if !decodeBody(w, r, &p) { + return + } + item, err := s.store().UpdatePlan(r.Context(), id, p) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": item}) + default: + writeJSON(w, http.StatusNotFound, apiErr("not_found", "平台 SaaS 接口不存在")) + } +} + +type saasShortLinkPayload struct { + extShortLinkPayload + RoutingStrategy string `json:"routing_strategy"` +} + +type shortTargetPayload struct { + ID int64 `json:"id"` + Name string `json:"name"` + TargetURL string `json:"target_url"` + Status string `json:"status"` + Weight int `json:"weight"` + SortOrder int `json:"sort_order"` + StartsAt string `json:"starts_at"` + ExpiresAt string `json:"expires_at"` + MaxHits int64 `json:"max_hits"` + HealthStatus string `json:"health_status"` +} + +type shortTargetsPayload struct { + RoutingStrategy string `json:"routing_strategy"` + Targets []shortTargetPayload `json:"targets"` +} + +func (s *Server) handleTenantShortLinks(w http.ResponseWriter, r *http.Request, path string) { + ta, ok := s.requireTenantActor(w, r) + if !ok { + return + } + if path == "/short-links" || path == "/short-links/" { + switch r.Method { + case http.MethodGet: + limit, offset := pagination(r) + items, err := s.store().ListShortLinksForTenant(r.Context(), ta.Tenant.ID, r.URL.Query().Get("q"), limit, offset) + if err != nil { + writeSaaSError(w, err) + return + } + links := map[int64]string{} + for _, item := range items { + links[item.ID] = publicShortURL(s.publicBaseURL(r), item.Code) + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items, "links": links}) + case http.MethodPost: + if !ta.canWrite() { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "当前角色不能创建短链")) + return + } + var p saasShortLinkPayload + if !decodeBody(w, r, &p) { + return + } + in, err := s.extShortPayloadToModel(p.extShortLinkPayload, true) + if err != nil { + writeJSON(w, http.StatusBadRequest, apiErr("bad_request", err.Error())) + return + } + in.OwnerAccountID = ta.Actor.Account.ID + created, err := s.store().CreateShortLinkForTenant(r.Context(), ta.Tenant.ID, in, p.RoutingStrategy) + if err != nil { + writeSaaSError(w, err) + return + } + _ = s.store().AuditTenant(r.Context(), deviceIDFromContext(r.Context()), ta.Tenant.ID, "short_link.create", "short_link", &created.ID, created.Code, util.ClientIP(r, s.cfg.TrustProxy)) + writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": created, "public_url": publicShortURL(s.publicBaseURL(r), created.Code)}) + default: + writeJSON(w, http.StatusMethodNotAllowed, apiErr("method", "method not allowed")) + } + return + } + id, tail, err := pathIDFromAPI(path, "/short-links/") + if err != nil { + writeJSON(w, http.StatusBadRequest, apiErr("bad_id", "短链 ID 不正确")) + return + } + current, err := s.store().GetShortLinkForTenant(r.Context(), id, ta.Tenant.ID, tail == "targets") + if err != nil { + writeSaaSError(w, err) + return + } + switch { + case tail == "" && r.Method == http.MethodGet: + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": current, "public_url": publicShortURL(s.publicBaseURL(r), current.Code)}) + case tail == "" && r.Method == http.MethodPut: + if !ta.canWrite() { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "当前角色不能修改短链")) + return + } + var p saasShortLinkPayload + if !decodeBody(w, r, &p) { + return + } + if strings.TrimSpace(p.RoutingStrategy) == "" { + p.RoutingStrategy = current.RoutingStrategy + } + in, err := s.extShortPayloadToModel(p.extShortLinkPayload, false) + if err != nil { + writeJSON(w, http.StatusBadRequest, apiErr("bad_request", err.Error())) + return + } + updated, err := s.store().UpdateShortLinkForTenant(r.Context(), id, ta.Tenant.ID, in, p.RoutingStrategy) + if err != nil { + writeSaaSError(w, err) + return + } + _ = s.store().AuditTenant(r.Context(), deviceIDFromContext(r.Context()), ta.Tenant.ID, "short_link.update", "short_link", &id, updated.Code, util.ClientIP(r, s.cfg.TrustProxy)) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": updated, "public_url": publicShortURL(s.publicBaseURL(r), updated.Code)}) + case tail == "" && r.Method == http.MethodDelete: + if !ta.canWrite() { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "当前角色不能删除短链")) + return + } + if err := s.store().DeleteShortLinkForTenant(r.Context(), id, ta.Tenant.ID); err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + case tail == "targets" && r.Method == http.MethodGet: + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "routing_strategy": current.RoutingStrategy, "data": current.Targets}) + case tail == "targets" && r.Method == http.MethodPut: + if !ta.canWrite() { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "当前角色不能修改目标池")) + return + } + var p shortTargetsPayload + if !decodeBody(w, r, &p) { + return + } + if strings.TrimSpace(p.RoutingStrategy) == "" { + p.RoutingStrategy = current.RoutingStrategy + } + targets, err := s.targetsFromPayload(p.Targets) + if err != nil { + writeJSON(w, http.StatusBadRequest, apiErr("bad_request", err.Error())) + return + } + items, err := s.store().SaveShortLinkTargets(r.Context(), id, ta.Tenant.ID, p.RoutingStrategy, targets) + if err != nil { + writeSaaSError(w, err) + return + } + _ = s.store().AuditTenant(r.Context(), deviceIDFromContext(r.Context()), ta.Tenant.ID, "short_link.targets.update", "short_link", &id, fmt.Sprintf("%d targets", len(items)), util.ClientIP(r, s.cfg.TrustProxy)) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "routing_strategy": p.RoutingStrategy, "data": items}) + case tail == "review" && r.Method == http.MethodPost: + if !ta.canReview() { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "当前角色没有租户审批权限")) + return + } + var p reviewPayload + if !decodeBody(w, r, &p) { + return + } + action := normalizeReviewAction("", p.Status) + if err := s.store().ReviewResourceTenant(r.Context(), ta.Tenant.ID, ta.Actor.Account.ID, "short_link", id, action, p.Note, false); err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + case tail == "stats" && r.Method == http.MethodGet: + days, _ := strconv.Atoi(r.URL.Query().Get("days")) + stats, err := s.store().Stats(r.Context(), "short_link", id, days) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": stats}) + case tail == "approval-events" && r.Method == http.MethodGet: + items, err := s.store().ApprovalEventsByResource(r.Context(), ta.Tenant.ID, "short_link", id) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) + default: + writeJSON(w, http.StatusNotFound, apiErr("not_found", "短链接口不存在")) + } +} + +func (s *Server) targetsFromPayload(raw []shortTargetPayload) ([]model.ShortLinkTarget, error) { + if len(raw) > 500 { + return nil, fmt.Errorf("目标数量不能超过 500") + } + out := make([]model.ShortLinkTarget, 0, len(raw)) + ids := map[int64]bool{} + for _, p := range raw { + if p.ID > 0 { + if ids[p.ID] { + return nil, fmt.Errorf("目标 ID %d 重复", p.ID) + } + ids[p.ID] = true + } + url := util.CleanURL(p.TargetURL) + if err := validateHTTPURL(url); err != nil { + return nil, fmt.Errorf("目标链接无效:%w", err) + } + status := strings.ToLower(strings.TrimSpace(p.Status)) + if status == "" { + status = "active" + } + if status != "active" && status != "disabled" { + return nil, fmt.Errorf("目标状态只支持 active/disabled") + } + health := strings.ToLower(strings.TrimSpace(p.HealthStatus)) + if health == "" { + health = "unknown" + } + if health != "unknown" && health != "healthy" && health != "unhealthy" { + return nil, fmt.Errorf("健康状态只支持 unknown/healthy/unhealthy") + } + if p.Weight < 0 || p.Weight > 10000 || p.MaxHits < 0 { + return nil, fmt.Errorf("目标权重或命中上限无效") + } + starts, err := util.ParseAPITime(p.StartsAt) + if err != nil { + return nil, err + } + expires, err := util.ParseAPITime(p.ExpiresAt) + if err != nil { + return nil, err + } + if starts != nil && expires != nil && !expires.After(*starts) { + return nil, fmt.Errorf("目标过期时间必须晚于开始时间") + } + out = append(out, model.ShortLinkTarget{ID: p.ID, Name: strings.TrimSpace(p.Name), TargetURL: url, Status: status, Weight: p.Weight, SortOrder: p.SortOrder, StartsAt: starts, ExpiresAt: expires, MaxHits: p.MaxHits, HealthStatus: health}) + } + return out, nil +} + +func (s *Server) handleTenantLiveQRs(w http.ResponseWriter, r *http.Request, path string) { + ta, ok := s.requireTenantActor(w, r) + if !ok { + return + } + if path == "/live-qrs/bundle" && r.Method == http.MethodPost { + if !ta.canWrite() { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "当前角色不能创建活码")) + return + } + if err := s.store().CheckTenantQuota(r.Context(), ta.Tenant.ID, "live_qrs", 1); err != nil { + writeSaaSError(w, err) + return + } + var p extLiveQRBundlePayload + if !decodeBody(w, r, &p) { + return + } + in, items, err := s.extBundlePayloadToModels(p, true) + if err != nil { + writeJSON(w, http.StatusBadRequest, apiErr("bad_request", err.Error())) + return + } + in.OwnerAccountID = ta.Actor.Account.ID + created, err := s.store().SaveLiveQRBundle(r.Context(), 0, in, items, nil) + if err != nil { + writeSaaSError(w, err) + return + } + if err := s.store().BindLiveQRToTenantAndReset(r.Context(), created.ID, ta.Tenant.ID, true); err != nil { + _ = s.store().DeleteLiveQR(r.Context(), created.ID) + writeSaaSError(w, err) + return + } + view, _ := s.store().GetLiveQRForTenant(r.Context(), created.ID, ta.Tenant.ID) + writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": view, "public_url": publicLiveURL(s.publicBaseURL(r), created.Code)}) + return + } + if path == "/live-qrs" || path == "/live-qrs/" { + switch r.Method { + case http.MethodGet: + limit, offset := pagination(r) + items, err := s.store().ListLiveQRsForTenant(r.Context(), ta.Tenant.ID, r.URL.Query().Get("q"), limit, offset) + if err != nil { + writeSaaSError(w, err) + return + } + links := map[int64]string{} + for _, item := range items { + links[item.ID] = publicLiveURL(s.publicBaseURL(r), item.Code) + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items, "links": links}) + case http.MethodPost: + if !ta.canWrite() { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "当前角色不能创建活码")) + return + } + if err := s.store().CheckTenantQuota(r.Context(), ta.Tenant.ID, "live_qrs", 1); err != nil { + writeSaaSError(w, err) + return + } + var p extLiveQRPayload + if !decodeBody(w, r, &p) { + return + } + in, err := s.extLivePayloadToModel(p, true) + if err != nil { + writeJSON(w, http.StatusBadRequest, apiErr("bad_request", err.Error())) + return + } + in.OwnerAccountID = ta.Actor.Account.ID + created, err := s.store().CreateLiveQR(r.Context(), in) + if err != nil { + writeSaaSError(w, err) + return + } + if err := s.store().BindLiveQRToTenantAndReset(r.Context(), created.ID, ta.Tenant.ID, true); err != nil { + _ = s.store().DeleteLiveQR(r.Context(), created.ID) + writeSaaSError(w, err) + return + } + view, _ := s.store().GetLiveQRForTenant(r.Context(), created.ID, ta.Tenant.ID) + writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": view, "public_url": publicLiveURL(s.publicBaseURL(r), created.Code)}) + default: + writeJSON(w, http.StatusMethodNotAllowed, apiErr("method", "method not allowed")) + } + return + } + id, tail, err := pathIDFromAPI(path, "/live-qrs/") + if err != nil { + writeJSON(w, http.StatusBadRequest, apiErr("bad_id", "活码 ID 不正确")) + return + } + current, err := s.store().GetLiveQRForTenant(r.Context(), id, ta.Tenant.ID) + if err != nil { + writeSaaSError(w, err) + return + } + switch { + case tail == "" && r.Method == http.MethodGet: + children, _ := s.store().ListLiveQRItems(r.Context(), id) + current.Items = children + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": current, "public_url": publicLiveURL(s.publicBaseURL(r), current.Code)}) + case tail == "" && r.Method == http.MethodPut: + if !ta.canWrite() { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "当前角色不能修改活码")) + return + } + var p extLiveQRPayload + if !decodeBody(w, r, &p) { + return + } + in, err := s.extLivePayloadToModel(p, false) + if err != nil { + writeJSON(w, http.StatusBadRequest, apiErr("bad_request", err.Error())) + return + } + updated, err := s.store().UpdateLiveQR(r.Context(), id, in) + if err != nil { + writeSaaSError(w, err) + return + } + if err := s.store().BindLiveQRToTenantAndReset(r.Context(), id, ta.Tenant.ID, false); err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": updated, "public_url": publicLiveURL(s.publicBaseURL(r), updated.Code)}) + case tail == "bundle" && r.Method == http.MethodPut: + if !ta.canWrite() { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "当前角色不能修改活码")) + return + } + var p extLiveQRBundlePayload + if !decodeBody(w, r, &p) { + return + } + in, items, err := s.extBundlePayloadToModels(p, false) + if err != nil { + writeJSON(w, http.StatusBadRequest, apiErr("bad_request", err.Error())) + return + } + in.OwnerAccountID = current.OwnerAccountID + updated, err := s.store().SaveLiveQRBundle(r.Context(), id, in, items, p.DeleteItemIDs) + if err != nil { + writeSaaSError(w, err) + return + } + if err := s.store().BindLiveQRToTenantAndReset(r.Context(), id, ta.Tenant.ID, false); err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": updated, "public_url": publicLiveURL(s.publicBaseURL(r), updated.Code)}) + case tail == "" && r.Method == http.MethodDelete: + if !ta.canWrite() { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "当前角色不能删除活码")) + return + } + if err := s.store().DeleteLiveQR(r.Context(), id); err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + case tail == "items" && r.Method == http.MethodGet: + items, err := s.store().ListLiveQRItems(r.Context(), id) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) + case tail == "items" && r.Method == http.MethodPost: + if !ta.canWrite() { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "当前角色不能新增活码项")) + return + } + var p liveQRItemPayload + if !decodeBody(w, r, &p) { + return + } + in, err := s.itemPayloadToModel(p) + if err != nil { + writeJSON(w, http.StatusBadRequest, apiErr("bad_request", err.Error())) + return + } + in.LiveQRID = id + created, err := s.store().CreateLiveQRItem(r.Context(), in) + if err != nil { + writeSaaSError(w, err) + return + } + if err := s.store().BindLiveQRItemAndReset(r.Context(), created.ID, id, ta.Tenant.ID, true); err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": created}) + case tail == "review" && r.Method == http.MethodPost: + if !ta.canReview() { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "当前角色没有租户审批权限")) + return + } + var p reviewPayload + if !decodeBody(w, r, &p) { + return + } + if err := s.store().ReviewResourceTenant(r.Context(), ta.Tenant.ID, ta.Actor.Account.ID, "live_qr", id, normalizeReviewAction("", p.Status), p.Note, p.IncludeItems); err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + case tail == "stats" && r.Method == http.MethodGet: + days, _ := strconv.Atoi(r.URL.Query().Get("days")) + stats, err := s.store().Stats(r.Context(), "live_qr", id, days) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": stats}) + case tail == "approval-events" && r.Method == http.MethodGet: + items, err := s.store().ApprovalEventsByResource(r.Context(), ta.Tenant.ID, "live_qr", id) + if err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) + default: + writeJSON(w, http.StatusNotFound, apiErr("not_found", "活码接口不存在")) + } +} + +func (s *Server) handleTenantLiveQRItem(w http.ResponseWriter, r *http.Request, path string) { + ta, ok := s.requireTenantActor(w, r) + if !ok { + return + } + id, tail, err := pathIDFromAPI(path, "/live-qr-items/") + if err != nil || tail != "" { + writeJSON(w, http.StatusBadRequest, apiErr("bad_id", "活码项 ID 不正确")) + return + } + item, err := s.store().GetLiveQRItemByID(r.Context(), id) + if err != nil { + writeSaaSError(w, err) + return + } + if _, err := s.store().GetLiveQRForTenant(r.Context(), item.LiveQRID, ta.Tenant.ID); err != nil { + writeSaaSError(w, err) + return + } + switch r.Method { + case http.MethodPut: + if !ta.canWrite() { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "当前角色不能修改活码项")) + return + } + var p liveQRItemPayload + if !decodeBody(w, r, &p) { + return + } + in, err := s.itemPayloadToModel(p) + if err != nil { + writeJSON(w, http.StatusBadRequest, apiErr("bad_request", err.Error())) + return + } + updated, err := s.store().UpdateLiveQRItem(r.Context(), id, in) + if err != nil { + writeSaaSError(w, err) + return + } + if err := s.store().BindLiveQRItemAndReset(r.Context(), id, item.LiveQRID, ta.Tenant.ID, false); err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": updated}) + case http.MethodDelete: + if !ta.canWrite() { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "当前角色不能删除活码项")) + return + } + if err := s.store().DeleteLiveQRItem(r.Context(), id); err != nil { + writeSaaSError(w, err) + return + } + if err := s.store().BindLiveQRToTenantAndReset(r.Context(), item.LiveQRID, ta.Tenant.ID, false); err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + case http.MethodPost: + if !ta.canReview() { + writeJSON(w, http.StatusForbidden, apiErr("forbidden", "当前角色没有租户审批权限")) + return + } + var p reviewPayload + if !decodeBody(w, r, &p) { + return + } + if err := s.store().ReviewResourceTenant(r.Context(), ta.Tenant.ID, ta.Actor.Account.ID, "live_qr_item", id, normalizeReviewAction("", p.Status), p.Note, false); err != nil { + writeSaaSError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + default: + writeJSON(w, http.StatusMethodNotAllowed, apiErr("method", "method not allowed")) + } +} + +func safeTenantSlug(slug, name string) string { + slug = strings.ToLower(strings.TrimSpace(slug)) + if slug == "" { + slug = strings.ToLower(strings.TrimSpace(name)) + } + var b strings.Builder + lastDash := false + for _, r := range slug { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + lastDash = false + } else if !lastDash && b.Len() > 0 { + b.WriteByte('-') + lastDash = true + } + } + out := strings.Trim(b.String(), "-") + if out == "" { + out = "workspace" + } + if len(out) > 80 { + out = strings.Trim(out[:80], "-") + } + return out + "-" + strconv.FormatInt(time.Now().UnixNano()%1000000, 10) +} + +func normalizeReviewAction(action, status string) string { + value := strings.ToLower(strings.TrimSpace(action)) + if value == "" { + value = strings.ToLower(strings.TrimSpace(status)) + } + if value == "approved" { + value = "approve" + } + if value == "rejected" { + value = "reject" + } + return value +} + +func splitPath(v string) []string { + raw := strings.Split(strings.Trim(v, "/"), "/") + out := make([]string, 0, len(raw)) + for _, part := range raw { + if strings.TrimSpace(part) != "" { + out = append(out, part) + } + } + return out +} + +func pathIDFromAPI(path, prefix string) (int64, string, error) { + parts := splitPath(strings.TrimPrefix(path, prefix)) + if len(parts) == 0 { + return 0, "", fmt.Errorf("missing id") + } + id, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil || id <= 0 { + return 0, "", fmt.Errorf("bad id") + } + tail := "" + if len(parts) > 1 { + tail = strings.Join(parts[1:], "/") + } + return id, tail, nil +} diff --git a/internal/server/server.go b/internal/server/server.go index 1c46327..1b69456 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -91,7 +91,7 @@ func (s *Server) Routes() http.Handler { mux.Handle("/admin", s.requireAuthPage(http.HandlerFunc(s.adminPage))) mux.Handle("/admin/", s.requireAuthPage(http.HandlerFunc(s.adminPage))) - for _, prefix := range []string{"/api/admin/users", "/api/admin/users/", "/api/admin/settings", "/api/admin/overview", "/api/admin/short-links", "/api/admin/short-links/", "/api/admin/live-qrs", "/api/admin/live-qrs/", "/api/admin/live-qr-items", "/api/admin/live-qr-items/"} { + for _, prefix := range []string{"/api/admin/users", "/api/admin/users/", "/api/admin/settings", "/api/admin/overview", "/api/admin/short-links", "/api/admin/short-links/", "/api/admin/live-qrs", "/api/admin/live-qrs/", "/api/admin/live-qr-items", "/api/admin/live-qr-items/", "/api/admin/saas", "/api/admin/saas/"} { mux.Handle(prefix, s.requireAuthAPI(http.HandlerFunc(s.adminAPIExt))) } mux.Handle("/api/admin/", s.requireAuthAPI(http.HandlerFunc(s.adminAPI))) diff --git a/internal/store/saas.go b/internal/store/saas.go new file mode 100644 index 0000000..3422709 --- /dev/null +++ b/internal/store/saas.go @@ -0,0 +1,1600 @@ +package store + +import ( + "context" + "crypto/rand" + "database/sql" + "errors" + "fmt" + "hash/fnv" + "math/big" + "sort" + "strconv" + "strings" + "time" + + "ai-shortlink/internal/model" +) + +var ( + ErrTenantForbidden = errors.New("tenant access forbidden") + ErrQuotaExceeded = errors.New("tenant quota exceeded") + ErrSubscriptionInactive = errors.New("tenant subscription inactive") + ErrApprovalState = errors.New("invalid approval state") + ErrNotPublished = errors.New("resource version is not published") + ErrVisitLimitReached = errors.New("visit limit reached") +) + +func (s *Store) lockSuffix() string { + if s.mode == "mysql" { + return " FOR UPDATE" + } + return "" +} + +func normalizeTenantRole(role string) string { + switch strings.ToLower(strings.TrimSpace(role)) { + case "owner": + return "owner" + case "admin": + return "admin" + case "reviewer": + return "reviewer" + case "analyst": + return "analyst" + default: + return "member" + } +} + +func tenantCanWrite(role string) bool { + switch normalizeTenantRole(role) { + case "owner", "admin", "member": + return true + default: + return false + } +} + +func tenantCanReview(role string) bool { + switch normalizeTenantRole(role) { + case "owner", "admin", "reviewer": + return true + default: + return false + } +} + +func scanTenant(scanner interface{ Scan(...any) error }) (*model.Tenant, error) { + var t model.Tenant + var owner sql.NullInt64 + if err := scanner.Scan(&t.ID, &t.Slug, &t.Name, &t.Kind, &t.Status, &owner, &t.CreatedAt, &t.UpdatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + if owner.Valid { + t.OwnerAccountID = owner.Int64 + } + return &t, nil +} + +const tenantSelectSQL = `SELECT id,slug,name,kind,status,owner_account_id,created_at,updated_at FROM tenants` + +func (s *Store) EnsurePersonalTenant(ctx context.Context, accountID int64) (*model.Tenant, string, error) { + if accountID <= 0 { + return nil, "", ErrTenantForbidden + } + row := s.db.QueryRowContext(ctx, tenantSelectSQL+` WHERE id=(SELECT tenant_id FROM tenant_members WHERE account_id=? AND status='active' ORDER BY CASE role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,id LIMIT 1)`, accountID) + if tenant, err := scanTenant(row); err == nil { + var role string + if err := s.db.QueryRowContext(ctx, `SELECT role FROM tenant_members WHERE tenant_id=? AND account_id=? AND status='active'`, tenant.ID, accountID).Scan(&role); err != nil { + return nil, "", err + } + if _, err := s.db.ExecContext(ctx, `UPDATE short_links SET tenant_id=? WHERE tenant_id IS NULL AND owner_account_id=?`, tenant.ID, accountID); err != nil { + return nil, "", err + } + if _, err := s.db.ExecContext(ctx, `UPDATE live_qrs SET tenant_id=? WHERE tenant_id IS NULL AND owner_account_id=?`, tenant.ID, accountID); err != nil { + return nil, "", err + } + return tenant, normalizeTenantRole(role), nil + } else if !errors.Is(err, ErrNotFound) { + return nil, "", err + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, "", err + } + defer func() { _ = tx.Rollback() }() + + var tenantID int64 + err = tx.QueryRowContext(ctx, `SELECT t.id FROM tenants t JOIN tenant_members m ON m.tenant_id=t.id WHERE m.account_id=? AND m.status='active' ORDER BY t.id LIMIT 1`+s.lockSuffix(), accountID).Scan(&tenantID) + if errors.Is(err, sql.ErrNoRows) { + var displayName, email string + if err := tx.QueryRowContext(ctx, `SELECT name,COALESCE(email,'') FROM admin_accounts WHERE id=?`, accountID).Scan(&displayName, &email); err != nil { + return nil, "", err + } + if strings.TrimSpace(displayName) == "" { + displayName = email + } + if strings.TrimSpace(displayName) == "" { + displayName = "Personal" + } + slug := "personal-" + strconv.FormatInt(accountID, 10) + res, insertErr := tx.ExecContext(ctx, `INSERT INTO tenants(slug,name,kind,status,owner_account_id) VALUES(?,?,?,?,?)`, slug, displayName+" Workspace", "personal", "active", accountID) + if insertErr != nil { + if err := tx.QueryRowContext(ctx, `SELECT id FROM tenants WHERE slug=?`, slug).Scan(&tenantID); err != nil { + return nil, "", insertErr + } + } else { + tenantID, _ = res.LastInsertId() + } + if _, err := tx.ExecContext(ctx, `INSERT INTO tenant_members(tenant_id,account_id,role,status) VALUES(?,?,?,?)`, tenantID, accountID, "owner", "active"); err != nil { + var exists int + if qerr := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM tenant_members WHERE tenant_id=? AND account_id=?`, tenantID, accountID).Scan(&exists); qerr != nil || exists == 0 { + return nil, "", err + } + } + } else if err != nil { + return nil, "", err + } + if err := ensureFreeSubscriptionTx(ctx, tx, tenantID); err != nil { + return nil, "", err + } + if _, err := tx.ExecContext(ctx, `UPDATE short_links SET tenant_id=? WHERE tenant_id IS NULL AND owner_account_id=?`, tenantID, accountID); err != nil { + return nil, "", err + } + if _, err := tx.ExecContext(ctx, `UPDATE live_qrs SET tenant_id=? WHERE tenant_id IS NULL AND owner_account_id=?`, tenantID, accountID); err != nil { + return nil, "", err + } + if err := tx.Commit(); err != nil { + return nil, "", err + } + tenant, err := s.GetTenant(ctx, tenantID) + if err != nil { + return nil, "", err + } + return tenant, "owner", nil +} + +func ensureFreeSubscriptionTx(ctx context.Context, tx *sql.Tx, tenantID int64) error { + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM tenant_subscriptions WHERE tenant_id=?`, tenantID).Scan(&count); err != nil { + return err + } + if count > 0 { + return nil + } + var planID int64 + if err := tx.QueryRowContext(ctx, `SELECT id FROM plans WHERE code='free' AND status='active' LIMIT 1`).Scan(&planID); err != nil { + return err + } + _, err := tx.ExecContext(ctx, `INSERT INTO tenant_subscriptions(tenant_id,plan_id,status,provider,current_period_start) VALUES(?,?,?,?,?)`, tenantID, planID, "active", "manual", now()) + return err +} + +func (s *Store) GetTenant(ctx context.Context, tenantID int64) (*model.Tenant, error) { + return scanTenant(s.db.QueryRowContext(ctx, tenantSelectSQL+` WHERE id=?`, tenantID)) +} + +func (s *Store) GetTenantAccess(ctx context.Context, tenantID, accountID int64) (*model.Tenant, *model.TenantMember, error) { + tenant, err := s.GetTenant(ctx, tenantID) + if err != nil { + return nil, nil, err + } + var member model.TenantMember + var email sql.NullString + if err := s.db.QueryRowContext(ctx, `SELECT m.id,m.tenant_id,m.account_id,COALESCE(a.email,''),a.name,m.role,m.status,m.created_at,m.updated_at FROM tenant_members m JOIN admin_accounts a ON a.id=m.account_id WHERE m.tenant_id=? AND m.account_id=? AND m.status='active' AND a.status='active'`, tenantID, accountID).Scan(&member.ID, &member.TenantID, &member.AccountID, &email, &member.Name, &member.Role, &member.Status, &member.CreatedAt, &member.UpdatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return tenant, nil, ErrTenantForbidden + } + return nil, nil, err + } + if email.Valid { + member.Email = email.String + } + member.Role = normalizeTenantRole(member.Role) + return tenant, &member, nil +} + +func (s *Store) ListTenantAccessForAccount(ctx context.Context, accountID int64) ([]model.TenantAccess, error) { + rows, err := s.db.QueryContext(ctx, `SELECT t.id,t.slug,t.name,t.kind,t.status,t.owner_account_id,t.created_at,t.updated_at,m.role FROM tenants t JOIN tenant_members m ON m.tenant_id=t.id WHERE m.account_id=? AND m.status='active' AND t.status='active' ORDER BY CASE m.role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,t.id`, accountID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []model.TenantAccess{} + for rows.Next() { + var t model.Tenant + var owner sql.NullInt64 + var role string + if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.Kind, &t.Status, &owner, &t.CreatedAt, &t.UpdatedAt, &role); err != nil { + return nil, err + } + if owner.Valid { + t.OwnerAccountID = owner.Int64 + } + out = append(out, model.TenantAccess{Tenant: t, Role: normalizeTenantRole(role)}) + } + return out, rows.Err() +} + +func (s *Store) CreateTenant(ctx context.Context, accountID int64, slug, name string) (*model.Tenant, error) { + slug = strings.ToLower(strings.TrimSpace(slug)) + name = strings.TrimSpace(name) + if slug == "" || name == "" { + return nil, fmt.Errorf("tenant slug and name are required") + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + res, err := tx.ExecContext(ctx, `INSERT INTO tenants(slug,name,kind,status,owner_account_id) VALUES(?,?,?,?,?)`, slug, name, "organization", "active", accountID) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + if _, err := tx.ExecContext(ctx, `INSERT INTO tenant_members(tenant_id,account_id,role,status) VALUES(?,?,?,?)`, id, accountID, "owner", "active"); err != nil { + return nil, err + } + if err := ensureFreeSubscriptionTx(ctx, tx, id); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return s.GetTenant(ctx, id) +} + +func (s *Store) UpdateTenant(ctx context.Context, tenantID int64, name, status string) (*model.Tenant, error) { + name = strings.TrimSpace(name) + status = strings.ToLower(strings.TrimSpace(status)) + if name == "" { + return nil, fmt.Errorf("tenant name is required") + } + if status != "active" && status != "disabled" { + return nil, fmt.Errorf("invalid tenant status") + } + res, err := s.db.ExecContext(ctx, `UPDATE tenants SET name=?,status=?,updated_at=? WHERE id=?`, name, status, now(), tenantID) + if err != nil { + return nil, err + } + if n, _ := res.RowsAffected(); n == 0 { + return nil, ErrNotFound + } + return s.GetTenant(ctx, tenantID) +} + +func (s *Store) ListTenantMembers(ctx context.Context, tenantID int64) ([]model.TenantMember, error) { + rows, err := s.db.QueryContext(ctx, `SELECT m.id,m.tenant_id,m.account_id,COALESCE(a.email,''),a.name,m.role,m.status,m.created_at,m.updated_at FROM tenant_members m JOIN admin_accounts a ON a.id=m.account_id WHERE m.tenant_id=? ORDER BY CASE m.role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 WHEN 'reviewer' THEN 2 WHEN 'member' THEN 3 ELSE 4 END,m.id`, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []model.TenantMember{} + for rows.Next() { + var m model.TenantMember + if err := rows.Scan(&m.ID, &m.TenantID, &m.AccountID, &m.Email, &m.Name, &m.Role, &m.Status, &m.CreatedAt, &m.UpdatedAt); err != nil { + return nil, err + } + m.Role = normalizeTenantRole(m.Role) + out = append(out, m) + } + return out, rows.Err() +} + +func (s *Store) UpsertTenantMember(ctx context.Context, tenantID, accountID int64, role, status string) (*model.TenantMember, error) { + role = normalizeTenantRole(role) + status = strings.ToLower(strings.TrimSpace(status)) + if status == "" { + status = "active" + } + if status != "active" && status != "disabled" { + return nil, fmt.Errorf("invalid member status") + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + var existingID int64 + var existingRole string + err = tx.QueryRowContext(ctx, `SELECT id,role FROM tenant_members WHERE tenant_id=? AND account_id=?`+s.lockSuffix(), tenantID, accountID).Scan(&existingID, &existingRole) + isNew := errors.Is(err, sql.ErrNoRows) + if err != nil && !isNew { + return nil, err + } + if isNew && status == "active" { + if err := checkQuotaTx(ctx, tx, s.mode, tenantID, "members", 1); err != nil { + return nil, err + } + } + if !isNew && normalizeTenantRole(existingRole) == "owner" && (role != "owner" || status != "active") { + var owners int64 + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM tenant_members WHERE tenant_id=? AND role='owner' AND status='active'`, tenantID).Scan(&owners); err != nil { + return nil, err + } + if owners <= 1 { + return nil, fmt.Errorf("the last active owner cannot be changed") + } + } + if isNew { + res, err := tx.ExecContext(ctx, `INSERT INTO tenant_members(tenant_id,account_id,role,status) VALUES(?,?,?,?)`, tenantID, accountID, role, status) + if err != nil { + return nil, err + } + existingID, _ = res.LastInsertId() + } else { + if _, err := tx.ExecContext(ctx, `UPDATE tenant_members SET role=?,status=?,updated_at=? WHERE id=?`, role, status, now(), existingID); err != nil { + return nil, err + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + members, err := s.ListTenantMembers(ctx, tenantID) + if err != nil { + return nil, err + } + for i := range members { + if members[i].AccountID == accountID { + return &members[i], nil + } + } + return nil, ErrNotFound +} + +func (s *Store) DeleteTenantMember(ctx context.Context, tenantID, accountID int64) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + var role, status string + if err := tx.QueryRowContext(ctx, `SELECT role,status FROM tenant_members WHERE tenant_id=? AND account_id=?`+s.lockSuffix(), tenantID, accountID).Scan(&role, &status); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + if normalizeTenantRole(role) == "owner" && status == "active" { + var owners int64 + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM tenant_members WHERE tenant_id=? AND role='owner' AND status='active'`, tenantID).Scan(&owners); err != nil { + return err + } + if owners <= 1 { + return fmt.Errorf("the last active owner cannot be removed") + } + } + if _, err := tx.ExecContext(ctx, `DELETE FROM tenant_members WHERE tenant_id=? AND account_id=?`, tenantID, accountID); err != nil { + return err + } + return tx.Commit() +} + +func scanPlan(scanner interface{ Scan(...any) error }) (*model.Plan, error) { + var p model.Plan + if err := scanner.Scan(&p.ID, &p.Code, &p.Name, &p.Description, &p.PriceMonthlyCents, &p.Currency, &p.MaxMembers, &p.MaxShortLinks, &p.MaxLiveQRs, &p.MaxTargetsPerLink, &p.MonthlyVisits, &p.FeaturesJSON, &p.Status, &p.CreatedAt, &p.UpdatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return &p, nil +} + +const planSelectSQL = `SELECT id,code,name,description,price_monthly_cents,currency,max_members,max_short_links,max_live_qrs,max_targets_per_link,monthly_visits,features_json,status,created_at,updated_at FROM plans` + +func (s *Store) ListPlans(ctx context.Context, includeDisabled bool) ([]model.Plan, error) { + query := planSelectSQL + if !includeDisabled { + query += ` WHERE status='active'` + } + query += ` ORDER BY price_monthly_cents,id` + rows, err := s.db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + out := []model.Plan{} + for rows.Next() { + p, err := scanPlan(rows) + if err != nil { + return nil, err + } + out = append(out, *p) + } + return out, rows.Err() +} + +func (s *Store) UpdatePlan(ctx context.Context, id int64, in model.Plan) (*model.Plan, error) { + if strings.TrimSpace(in.Name) == "" { + return nil, fmt.Errorf("plan name is required") + } + for _, v := range []int64{in.PriceMonthlyCents, in.MaxMembers, in.MaxShortLinks, in.MaxLiveQRs, in.MaxTargetsPerLink, in.MonthlyVisits} { + if v < 0 { + return nil, fmt.Errorf("plan limits cannot be negative") + } + } + if in.Status != "active" && in.Status != "disabled" { + return nil, fmt.Errorf("invalid plan status") + } + res, err := s.db.ExecContext(ctx, `UPDATE plans SET name=?,description=?,price_monthly_cents=?,currency=?,max_members=?,max_short_links=?,max_live_qrs=?,max_targets_per_link=?,monthly_visits=?,features_json=?,status=?,updated_at=? WHERE id=?`, strings.TrimSpace(in.Name), in.Description, in.PriceMonthlyCents, strings.ToUpper(strings.TrimSpace(in.Currency)), in.MaxMembers, in.MaxShortLinks, in.MaxLiveQRs, in.MaxTargetsPerLink, in.MonthlyVisits, in.FeaturesJSON, in.Status, now(), id) + if err != nil { + return nil, err + } + if n, _ := res.RowsAffected(); n == 0 { + return nil, ErrNotFound + } + return scanPlan(s.db.QueryRowContext(ctx, planSelectSQL+` WHERE id=?`, id)) +} + +func scanSubscription(scanner interface{ Scan(...any) error }) (*model.TenantSubscription, error) { + var sub model.TenantSubscription + var externalCustomer, externalSub sql.NullString + var start, end, trial sql.NullTime + var cancel int + if err := scanner.Scan(&sub.ID, &sub.TenantID, &sub.PlanID, &sub.PlanCode, &sub.PlanName, &sub.Status, &sub.Provider, &externalCustomer, &externalSub, &start, &end, &cancel, &trial, &sub.CreatedAt, &sub.UpdatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + if externalCustomer.Valid { + sub.ExternalCustomerID = externalCustomer.String + } + if externalSub.Valid { + sub.ExternalSubscriptionID = externalSub.String + } + if start.Valid { + sub.CurrentPeriodStart = &start.Time + } + if end.Valid { + sub.CurrentPeriodEnd = &end.Time + } + if trial.Valid { + sub.TrialEndsAt = &trial.Time + } + sub.CancelAtPeriodEnd = cancel != 0 + return &sub, nil +} + +const subscriptionSelectSQL = `SELECT s.id,s.tenant_id,s.plan_id,p.code,p.name,s.status,s.provider,s.external_customer_id,s.external_subscription_id,s.current_period_start,s.current_period_end,s.cancel_at_period_end,s.trial_ends_at,s.created_at,s.updated_at FROM tenant_subscriptions s JOIN plans p ON p.id=s.plan_id` + +func (s *Store) GetTenantSubscription(ctx context.Context, tenantID int64) (*model.TenantSubscription, error) { + return scanSubscription(s.db.QueryRowContext(ctx, subscriptionSelectSQL+` WHERE s.tenant_id=?`, tenantID)) +} + +func subscriptionUsable(status string, periodEnd *time.Time, trialEnd *time.Time, n time.Time) bool { + status = strings.ToLower(strings.TrimSpace(status)) + if status != "active" && status != "trialing" && status != "trial" { + return false + } + if status == "trialing" || status == "trial" { + if trialEnd != nil && !trialEnd.After(n) { + return false + } + } + if periodEnd != nil && !periodEnd.After(n) { + return false + } + return true +} + +func monthKey(t time.Time) string { return t.UTC().Format("2006-01") } + +func (s *Store) GetTenantQuotaSnapshot(ctx context.Context, tenantID int64) (*model.TenantQuotaSnapshot, error) { + sub, err := s.GetTenantSubscription(ctx, tenantID) + if err != nil { + return nil, err + } + plan, err := scanPlan(s.db.QueryRowContext(ctx, planSelectSQL+` WHERE id=?`, sub.PlanID)) + if err != nil { + return nil, err + } + var snap model.TenantQuotaSnapshot + snap.Plan = *plan + snap.Subscription = *sub + snap.PeriodKey = monthKey(now()) + queries := []struct { + q string + d *int64 + }{ + {`SELECT COUNT(*) FROM tenant_members WHERE tenant_id=? AND status='active'`, &snap.MembersUsed}, + {`SELECT COUNT(*) FROM short_links WHERE tenant_id=?`, &snap.ShortLinksUsed}, + {`SELECT COUNT(*) FROM live_qrs WHERE tenant_id=?`, &snap.LiveQRsUsed}, + } + for _, q := range queries { + if err := s.db.QueryRowContext(ctx, q.q, tenantID).Scan(q.d); err != nil { + return nil, err + } + } + _ = s.db.QueryRowContext(ctx, `SELECT visits FROM tenant_usage_monthly WHERE tenant_id=? AND period_key=?`, tenantID, snap.PeriodKey).Scan(&snap.MonthlyVisitsUsed) + return &snap, nil +} + +func planAndSubscriptionTx(ctx context.Context, tx *sql.Tx, mode string, tenantID int64) (*model.Plan, *model.TenantSubscription, error) { + lock := "" + if mode == "mysql" { + lock = " FOR UPDATE" + } + sub, err := scanSubscription(tx.QueryRowContext(ctx, subscriptionSelectSQL+` WHERE s.tenant_id=?`+lock, tenantID)) + if err != nil { + return nil, nil, err + } + plan, err := scanPlan(tx.QueryRowContext(ctx, planSelectSQL+` WHERE id=?`, sub.PlanID)) + if err != nil { + return nil, nil, err + } + if !subscriptionUsable(sub.Status, sub.CurrentPeriodEnd, sub.TrialEndsAt, now()) { + return nil, nil, ErrSubscriptionInactive + } + return plan, sub, nil +} + +func checkQuotaTx(ctx context.Context, tx *sql.Tx, mode string, tenantID int64, resource string, delta int64) error { + plan, _, err := planAndSubscriptionTx(ctx, tx, mode, tenantID) + if err != nil { + return err + } + var limit int64 + var count int64 + switch resource { + case "members": + limit = plan.MaxMembers + err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM tenant_members WHERE tenant_id=? AND status='active'`, tenantID).Scan(&count) + case "short_links": + limit = plan.MaxShortLinks + err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM short_links WHERE tenant_id=?`, tenantID).Scan(&count) + case "live_qrs": + limit = plan.MaxLiveQRs + err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM live_qrs WHERE tenant_id=?`, tenantID).Scan(&count) + default: + return fmt.Errorf("unknown quota resource %q", resource) + } + if err != nil { + return err + } + if limit > 0 && count+delta > limit { + return fmt.Errorf("%w: %s limit is %d", ErrQuotaExceeded, resource, limit) + } + return nil +} + +func (s *Store) CheckTenantQuota(ctx context.Context, tenantID int64, resource string, delta int64) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if err := checkQuotaTx(ctx, tx, s.mode, tenantID, resource, delta); err != nil { + return err + } + return tx.Commit() +} + +func ensureUsageTx(ctx context.Context, tx *sql.Tx, tenantID int64, period string) error { + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM tenant_usage_monthly WHERE tenant_id=? AND period_key=?`, tenantID, period).Scan(&count); err != nil { + return err + } + if count == 0 { + if _, err := tx.ExecContext(ctx, `INSERT INTO tenant_usage_monthly(tenant_id,period_key) VALUES(?,?)`, tenantID, period); err != nil { + var after int + if qerr := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM tenant_usage_monthly WHERE tenant_id=? AND period_key=?`, tenantID, period).Scan(&after); qerr != nil || after == 0 { + return err + } + } + } + return nil +} + +func (s *Store) RequestPlanChange(ctx context.Context, tenantID, accountID, toPlanID int64, note string) (*model.SubscriptionChangeRequest, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + var fromPlanID int64 + if err := tx.QueryRowContext(ctx, `SELECT plan_id FROM tenant_subscriptions WHERE tenant_id=?`+s.lockSuffix(), tenantID).Scan(&fromPlanID); err != nil { + return nil, err + } + var active int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM plans WHERE id=? AND status='active'`, toPlanID).Scan(&active); err != nil || active == 0 { + if err != nil { + return nil, err + } + return nil, ErrNotFound + } + if fromPlanID == toPlanID { + return nil, fmt.Errorf("tenant is already on this plan") + } + res, err := tx.ExecContext(ctx, `INSERT INTO subscription_change_requests(tenant_id,from_plan_id,to_plan_id,status,pending_guard,note,requested_by) VALUES(?,?,?,?,?,?,?)`, tenantID, fromPlanID, toPlanID, "pending", "pending", strings.TrimSpace(note), accountID) + if err != nil { + return nil, fmt.Errorf("pending subscription request already exists: %w", err) + } + id, _ := res.LastInsertId() + if err := tx.Commit(); err != nil { + return nil, err + } + return s.GetSubscriptionRequest(ctx, id) +} + +func scanSubscriptionRequest(scanner interface{ Scan(...any) error }) (*model.SubscriptionChangeRequest, error) { + var r model.SubscriptionChangeRequest + var reviewedBy sql.NullInt64 + var reviewedAt sql.NullTime + if err := scanner.Scan(&r.ID, &r.TenantID, &r.TenantName, &r.FromPlanID, &r.FromPlan, &r.ToPlanID, &r.ToPlan, &r.Status, &r.Note, &r.ReviewNote, &r.RequestedBy, &reviewedBy, &reviewedAt, &r.CreatedAt, &r.UpdatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + if reviewedBy.Valid { + r.ReviewedBy = reviewedBy.Int64 + } + if reviewedAt.Valid { + r.ReviewedAt = &reviewedAt.Time + } + return &r, nil +} + +const subscriptionRequestSelectSQL = `SELECT r.id,r.tenant_id,t.name,r.from_plan_id,pf.name,r.to_plan_id,pt.name,r.status,r.note,r.review_note,r.requested_by,r.reviewed_by,r.reviewed_at,r.created_at,r.updated_at FROM subscription_change_requests r JOIN tenants t ON t.id=r.tenant_id JOIN plans pf ON pf.id=r.from_plan_id JOIN plans pt ON pt.id=r.to_plan_id` + +func (s *Store) GetSubscriptionRequest(ctx context.Context, id int64) (*model.SubscriptionChangeRequest, error) { + return scanSubscriptionRequest(s.db.QueryRowContext(ctx, subscriptionRequestSelectSQL+` WHERE r.id=?`, id)) +} + +func (s *Store) ListSubscriptionRequests(ctx context.Context, tenantID int64, platform bool) ([]model.SubscriptionChangeRequest, error) { + query := subscriptionRequestSelectSQL + args := []any{} + if !platform { + query += ` WHERE r.tenant_id=?` + args = append(args, tenantID) + } + query += ` ORDER BY CASE r.status WHEN 'pending' THEN 0 ELSE 1 END,r.id DESC LIMIT 200` + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []model.SubscriptionChangeRequest{} + for rows.Next() { + r, err := scanSubscriptionRequest(rows) + if err != nil { + return nil, err + } + out = append(out, *r) + } + return out, rows.Err() +} + +func (s *Store) ReviewSubscriptionRequest(ctx context.Context, requestID, reviewerID int64, action, note string) (*model.SubscriptionChangeRequest, error) { + action = strings.ToLower(strings.TrimSpace(action)) + if action != "approve" && action != "reject" { + return nil, fmt.Errorf("action must be approve or reject") + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + var tenantID, toPlanID int64 + var status string + if err := tx.QueryRowContext(ctx, `SELECT tenant_id,to_plan_id,status FROM subscription_change_requests WHERE id=?`+s.lockSuffix(), requestID).Scan(&tenantID, &toPlanID, &status); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + if status != "pending" { + return nil, ErrApprovalState + } + newStatus := "rejected" + if action == "approve" { + newStatus = "approved" + if _, err := tx.ExecContext(ctx, `UPDATE tenant_subscriptions SET plan_id=?,status='active',provider='manual',current_period_start=?,current_period_end=NULL,cancel_at_period_end=0,updated_at=? WHERE tenant_id=?`, toPlanID, now(), now(), tenantID); err != nil { + return nil, err + } + } + if _, err := tx.ExecContext(ctx, `UPDATE subscription_change_requests SET status=?,pending_guard=NULL,review_note=?,reviewed_by=?,reviewed_at=?,updated_at=? WHERE id=?`, newStatus, strings.TrimSpace(note), reviewerID, now(), now(), requestID); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return s.GetSubscriptionRequest(ctx, requestID) +} + +func (s *Store) hydrateShortWorkspace(ctx context.Context, link *model.ShortLink, includeTargets bool) (*model.ShortLinkWorkspace, error) { + var w model.ShortLinkWorkspace + w.ShortLink = *link + if err := s.db.QueryRowContext(ctx, `SELECT tenant_id,routing_strategy,current_target_cursor,content_version,approved_version FROM short_links WHERE id=?`, link.ID).Scan(&w.TenantID, &w.RoutingStrategy, &w.CurrentTargetCursor, &w.ContentVersion, &w.ApprovedVersion); err != nil { + return nil, err + } + if includeTargets { + targets, err := s.ListShortLinkTargets(ctx, link.ID, w.TenantID) + if err != nil { + return nil, err + } + w.Targets = targets + } + return &w, nil +} + +func (s *Store) GetShortLinkForTenant(ctx context.Context, id, tenantID int64, includeTargets bool) (*model.ShortLinkWorkspace, error) { + link, err := scanShort(s.db.QueryRowContext(ctx, shortSelectSQL()+` WHERE id=? AND tenant_id=?`, id, tenantID)) + if err != nil { + return nil, err + } + return s.hydrateShortWorkspace(ctx, link, includeTargets) +} + +func (s *Store) ListShortLinksForTenant(ctx context.Context, tenantID int64, q string, limit, offset int) ([]model.ShortLinkWorkspace, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + query := shortSelectSQL() + ` WHERE tenant_id=?` + args := []any{tenantID} + if strings.TrimSpace(q) != "" { + like := "%" + strings.TrimSpace(q) + "%" + query += ` AND (code LIKE ? OR title LIKE ? OR target_url LIKE ? OR approval_status LIKE ?)` + args = append(args, like, like, like, like) + } + query += ` ORDER BY id DESC LIMIT ? OFFSET ?` + args = append(args, limit, offset) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + links := []*model.ShortLink{} + for rows.Next() { + link, err := scanShort(rows) + if err != nil { + return nil, err + } + links = append(links, link) + } + if err := rows.Close(); err != nil { + return nil, err + } + out := make([]model.ShortLinkWorkspace, 0, len(links)) + for _, link := range links { + w, err := s.hydrateShortWorkspace(ctx, link, false) + if err != nil { + return nil, err + } + out = append(out, *w) + } + return out, nil +} + +func normalizeRoutingStrategy(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "round_robin", "random", "weighted_random", "least_used", "ip_hash": + return strings.ToLower(strings.TrimSpace(v)) + default: + return "single" + } +} + +func (s *Store) CreateShortLinkForTenant(ctx context.Context, tenantID int64, in *model.ShortLink, strategy string) (*model.ShortLinkWorkspace, error) { + normalizeShortLink(in) + strategy = normalizeRoutingStrategy(strategy) + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + if err := checkQuotaTx(ctx, tx, s.mode, tenantID, "short_links", 1); err != nil { + return nil, err + } + res, err := tx.ExecContext(ctx, `INSERT INTO short_links(owner_account_id,tenant_id,code,title,target_url,status,approval_status,redirect_type,starts_at,expires_at,max_visits,fallback_url,remark,qr_style,qr_foreground,qr_background,qr_logo_url,routing_strategy,content_version,approved_version) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,0)`, nullInt64(in.OwnerAccountID), tenantID, in.Code, in.Title, in.TargetURL, in.Status, "tenant_pending", in.RedirectType, in.StartsAt, in.ExpiresAt, in.MaxVisits, nullString(in.FallbackURL), nullString(in.Remark), in.QRStyle, in.QRForeground, in.QRBackground, nullString(in.QRLogoURL), strategy) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + period := monthKey(now()) + if err := ensureUsageTx(ctx, tx, tenantID, period); err != nil { + return nil, err + } + if _, err := tx.ExecContext(ctx, `UPDATE tenant_usage_monthly SET short_links_created=short_links_created+1,updated_at=? WHERE tenant_id=? AND period_key=?`, now(), tenantID, period); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return s.GetShortLinkForTenant(ctx, id, tenantID, true) +} + +func (s *Store) UpdateShortLinkForTenant(ctx context.Context, id, tenantID int64, in *model.ShortLink, strategy string) (*model.ShortLinkWorkspace, error) { + normalizeShortLink(in) + strategy = normalizeRoutingStrategy(strategy) + res, err := s.db.ExecContext(ctx, `UPDATE short_links SET code=?,title=?,target_url=?,status=?,approval_status='tenant_pending',approved_at=NULL,reviewed_at=NULL,review_note=NULL,redirect_type=?,starts_at=?,expires_at=?,max_visits=?,fallback_url=?,remark=?,qr_style=?,qr_foreground=?,qr_background=?,qr_logo_url=?,routing_strategy=?,content_version=content_version+1,approved_version=0,updated_at=? WHERE id=? AND tenant_id=?`, in.Code, in.Title, in.TargetURL, in.Status, in.RedirectType, in.StartsAt, in.ExpiresAt, in.MaxVisits, nullString(in.FallbackURL), nullString(in.Remark), in.QRStyle, in.QRForeground, in.QRBackground, nullString(in.QRLogoURL), strategy, now(), id, tenantID) + if err != nil { + return nil, err + } + if n, _ := res.RowsAffected(); n == 0 { + return nil, ErrNotFound + } + return s.GetShortLinkForTenant(ctx, id, tenantID, true) +} + +func (s *Store) DeleteShortLinkForTenant(ctx context.Context, id, tenantID int64) error { + res, err := s.db.ExecContext(ctx, `DELETE FROM short_links WHERE id=? AND tenant_id=?`, id, tenantID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +func scanShortTarget(scanner interface{ Scan(...any) error }) (*model.ShortLinkTarget, error) { + var t model.ShortLinkTarget + var starts, expires, lastHit sql.NullTime + if err := scanner.Scan(&t.ID, &t.TenantID, &t.ShortLinkID, &t.Name, &t.TargetURL, &t.Status, &t.Weight, &t.SortOrder, &starts, &expires, &t.MaxHits, &t.HitCount, &t.HealthStatus, &lastHit, &t.CreatedAt, &t.UpdatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + if starts.Valid { + t.StartsAt = &starts.Time + } + if expires.Valid { + t.ExpiresAt = &expires.Time + } + if lastHit.Valid { + t.LastHitAt = &lastHit.Time + } + return &t, nil +} + +const shortTargetSelectSQL = `SELECT id,tenant_id,short_link_id,name,target_url,status,weight,sort_order,starts_at,expires_at,max_hits,hit_count,health_status,last_hit_at,created_at,updated_at FROM short_link_targets` + +func listShortTargetsQuery(ctx context.Context, q interface { + QueryContext(context.Context, string, ...any) (*sql.Rows, error) +}, shortID, tenantID int64, onlyAvailable bool, lock bool, mode string) ([]model.ShortLinkTarget, error) { + query := shortTargetSelectSQL + ` WHERE short_link_id=? AND tenant_id=?` + args := []any{shortID, tenantID} + if onlyAvailable { + n := now() + query += ` AND status='active' AND health_status<>'unhealthy' AND (starts_at IS NULL OR starts_at<=?) AND (expires_at IS NULL OR expires_at>?) AND (max_hits=0 OR hit_count 0 && int64(len(targets)) > plan.MaxTargetsPerLink { + return nil, fmt.Errorf("%w: target limit is %d", ErrQuotaExceeded, plan.MaxTargetsPerLink) + } + existing, err := listShortTargetsQuery(ctx, tx, shortID, tenantID, false, true, s.mode) + if err != nil { + return nil, err + } + keep := map[int64]bool{} + for i := range targets { + t := &targets[i] + if t.Weight <= 0 { + t.Weight = 1 + } + if t.SortOrder == 0 { + t.SortOrder = 100 + } + if t.Status == "" { + t.Status = "active" + } + if t.HealthStatus == "" { + t.HealthStatus = "unknown" + } + if t.ID > 0 { + res, err := tx.ExecContext(ctx, `UPDATE short_link_targets SET name=?,target_url=?,status=?,weight=?,sort_order=?,starts_at=?,expires_at=?,max_hits=?,health_status=?,updated_at=? WHERE id=? AND short_link_id=? AND tenant_id=?`, t.Name, t.TargetURL, t.Status, t.Weight, t.SortOrder, t.StartsAt, t.ExpiresAt, t.MaxHits, t.HealthStatus, now(), t.ID, shortID, tenantID) + if err != nil { + return nil, err + } + if n, _ := res.RowsAffected(); n == 0 { + return nil, ErrNotFound + } + keep[t.ID] = true + } else { + res, err := tx.ExecContext(ctx, `INSERT INTO short_link_targets(tenant_id,short_link_id,name,target_url,status,weight,sort_order,starts_at,expires_at,max_hits,health_status) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, tenantID, shortID, t.Name, t.TargetURL, t.Status, t.Weight, t.SortOrder, t.StartsAt, t.ExpiresAt, t.MaxHits, t.HealthStatus) + if err != nil { + return nil, err + } + t.ID, _ = res.LastInsertId() + keep[t.ID] = true + } + } + for _, old := range existing { + if !keep[old.ID] { + if _, err := tx.ExecContext(ctx, `DELETE FROM short_link_targets WHERE id=? AND short_link_id=? AND tenant_id=?`, old.ID, shortID, tenantID); err != nil { + return nil, err + } + } + } + if _, err := tx.ExecContext(ctx, `UPDATE short_links SET routing_strategy=?,current_target_cursor=0,approval_status='tenant_pending',approved_at=NULL,reviewed_at=NULL,review_note=NULL,content_version=content_version+1,approved_version=0,updated_at=? WHERE id=? AND tenant_id=?`, strategy, now(), shortID, tenantID); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return s.ListShortLinkTargets(ctx, shortID, tenantID) +} + +func uniformIndex(length int) int { + if length <= 1 { + return 0 + } + n, err := rand.Int(rand.Reader, big.NewInt(int64(length))) + if err != nil { + return int(time.Now().UnixNano() % int64(length)) + } + return int(n.Int64()) +} + +func weightedTargetIndex(items []model.ShortLinkTarget) int { + var total int64 + for _, item := range items { + weight := item.Weight + if weight <= 0 { + weight = 1 + } + total += int64(weight) + } + if total <= 0 { + return 0 + } + n, err := rand.Int(rand.Reader, big.NewInt(total)) + if err != nil { + return uniformIndex(len(items)) + } + threshold := n.Int64() + var running int64 + for i, item := range items { + weight := item.Weight + if weight <= 0 { + weight = 1 + } + running += int64(weight) + if threshold < running { + return i + } + } + return len(items) - 1 +} + +func ipHashIndex(key string, length int) int { + if length <= 1 { + return 0 + } + h := fnv.New64a() + _, _ = h.Write([]byte(key)) + return int(h.Sum64() % uint64(length)) +} + +func selectTargetIndex(strategy string, cursor int64, clientKey string, items []model.ShortLinkTarget) int { + if len(items) == 0 { + return -1 + } + switch normalizeRoutingStrategy(strategy) { + case "round_robin": + return int(cursor % int64(len(items))) + case "random": + return uniformIndex(len(items)) + case "weighted_random": + return weightedTargetIndex(items) + case "least_used": + idx := 0 + for i := 1; i < len(items); i++ { + if items[i].HitCount < items[idx].HitCount { + idx = i + } + } + return idx + case "ip_hash": + return ipHashIndex(clientKey, len(items)) + default: + return 0 + } +} + +func (s *Store) SelectShortTargetForVisit(ctx context.Context, shortID int64, clientKey string) (*model.ShortRouteDecision, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + query := `SELECT tenant_id,target_url,status,approval_status,starts_at,expires_at,max_visits,visit_count,routing_strategy,current_target_cursor,content_version,approved_version FROM short_links WHERE id=?` + s.lockSuffix() + var tenantID, maxVisits, visits, cursor, contentVersion, approvedVersion int64 + var legacyURL, status, approval, strategy string + var starts, expires sql.NullTime + if err := tx.QueryRowContext(ctx, query, shortID).Scan(&tenantID, &legacyURL, &status, &approval, &starts, &expires, &maxVisits, &visits, &strategy, &cursor, &contentVersion, &approvedVersion); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + n := now() + if approval != "approved" || contentVersion != approvedVersion { + return nil, ErrNotPublished + } + if status != "active" { + return nil, fmt.Errorf("resource disabled") + } + if starts.Valid && n.Before(starts.Time) { + return nil, fmt.Errorf("resource not started") + } + if expires.Valid && !n.Before(expires.Time) { + return nil, fmt.Errorf("resource expired") + } + if maxVisits > 0 && visits >= maxVisits { + return nil, ErrVisitLimitReached + } + plan, _, err := planAndSubscriptionTx(ctx, tx, s.mode, tenantID) + if err != nil { + return nil, err + } + period := monthKey(n) + if err := ensureUsageTx(ctx, tx, tenantID, period); err != nil { + return nil, err + } + var used int64 + usageQuery := `SELECT visits FROM tenant_usage_monthly WHERE tenant_id=? AND period_key=?` + if s.mode == "mysql" { + usageQuery += ` FOR UPDATE` + } + if err := tx.QueryRowContext(ctx, usageQuery, tenantID, period).Scan(&used); err != nil { + return nil, err + } + if plan.MonthlyVisits > 0 && used >= plan.MonthlyVisits { + return nil, fmt.Errorf("%w: monthly visit limit is %d", ErrQuotaExceeded, plan.MonthlyVisits) + } + targets, err := listShortTargetsQuery(ctx, tx, shortID, tenantID, true, true, s.mode) + if err != nil { + return nil, err + } + decision := &model.ShortRouteDecision{TenantID: tenantID, TargetURL: legacyURL, Strategy: normalizeRoutingStrategy(strategy), Counted: true} + idx := selectTargetIndex(strategy, cursor, clientKey, targets) + if idx >= 0 { + chosen := targets[idx] + decision.TargetURL = chosen.TargetURL + decision.TargetID = &chosen.ID + if _, err := tx.ExecContext(ctx, `UPDATE short_link_targets SET hit_count=hit_count+1,last_hit_at=?,updated_at=? WHERE id=?`, n, n, chosen.ID); err != nil { + return nil, err + } + } + if normalizeRoutingStrategy(strategy) == "round_robin" && len(targets) > 0 { + if _, err := tx.ExecContext(ctx, `UPDATE short_links SET current_target_cursor=current_target_cursor+1 WHERE id=?`, shortID); err != nil { + return nil, err + } + } + if _, err := tx.ExecContext(ctx, `UPDATE short_links SET visit_count=visit_count+1 WHERE id=?`, shortID); err != nil { + return nil, err + } + if _, err := tx.ExecContext(ctx, `UPDATE tenant_usage_monthly SET visits=visits+1,updated_at=? WHERE tenant_id=? AND period_key=?`, n, tenantID, period); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return decision, nil +} + +func (s *Store) RecordVisitSaaS(ctx context.Context, v *model.VisitLog, tenantID int64, targetID *int64) error { + _, err := s.db.ExecContext(ctx, `INSERT INTO visit_logs(resource_type,resource_id,item_id,code,event_type,status,target_url,ip,ip_hash,user_agent,referer,device_type,browser,os,tenant_id,target_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, v.ResourceType, v.ResourceID, v.ItemID, v.Code, v.EventType, v.Status, nullString(v.TargetURL), v.IP, v.IPHash, v.UserAgent, nullString(v.Referer), v.DeviceType, v.Browser, v.OS, nullInt64(tenantID), targetID) + return err +} + +func (s *Store) AuditTenant(ctx context.Context, actorDeviceID *int64, tenantID int64, action, resourceType string, resourceID *int64, detail, ip string) error { + _, err := s.db.ExecContext(ctx, `INSERT INTO audit_logs(actor_device_id,tenant_id,action,resource_type,resource_id,detail,ip) VALUES(?,?,?,?,?,?,?)`, actorDeviceID, nullInt64(tenantID), action, resourceType, resourceID, detail, ip) + return err +} + +func resourceTable(resourceType string) (table string, parentJoin string, err error) { + switch resourceType { + case "short_link": + return "short_links", "", nil + case "live_qr": + return "live_qrs", "", nil + case "live_qr_item": + return "live_qr_items", "live_qrs", nil + default: + return "", "", fmt.Errorf("unsupported resource type") + } +} + +func getResourceReviewStateTx(ctx context.Context, tx *sql.Tx, mode, resourceType string, resourceID int64) (tenantID, version int64, status string, err error) { + table, parent, err := resourceTable(resourceType) + if err != nil { + return 0, 0, "", err + } + lock := "" + if mode == "mysql" { + lock = " FOR UPDATE" + } + if parent == "" { + query := fmt.Sprintf(`SELECT tenant_id,content_version,approval_status FROM %s WHERE id=?%s`, table, lock) + err = tx.QueryRowContext(ctx, query, resourceID).Scan(&tenantID, &version, &status) + } else { + query := fmt.Sprintf(`SELECT l.tenant_id,i.content_version,i.approval_status FROM %s i JOIN %s l ON l.id=i.live_qr_id WHERE i.id=?%s`, table, parent, lock) + err = tx.QueryRowContext(ctx, query, resourceID).Scan(&tenantID, &version, &status) + } + if errors.Is(err, sql.ErrNoRows) { + err = ErrNotFound + } + return +} + +func updateReviewStateTx(ctx context.Context, tx *sql.Tx, resourceType string, resourceID int64, status string, approved bool, note string) error { + table, _, err := resourceTable(resourceType) + if err != nil { + return err + } + approvedExpr := "0" + approvedAt := any(nil) + if approved { + approvedExpr = "content_version" + approvedAt = now() + } + query := fmt.Sprintf(`UPDATE %s SET approval_status=?,approved_version=%s,approved_at=?,reviewed_at=?,review_note=?,updated_at=? WHERE id=?`, table, approvedExpr) + _, err = tx.ExecContext(ctx, query, status, approvedAt, now(), nullString(note), now(), resourceID) + return err +} + +func insertApprovalEventTx(ctx context.Context, tx *sql.Tx, tenantID int64, resourceType string, resourceID, version int64, stage, action string, actorID int64, note string) error { + _, err := tx.ExecContext(ctx, `INSERT INTO approval_events(tenant_id,resource_type,resource_id,content_version,stage,action,actor_account_id,note) VALUES(?,?,?,?,?,?,?,?)`, tenantID, resourceType, resourceID, version, stage, action, actorID, strings.TrimSpace(note)) + return err +} + +func (s *Store) ReviewResourceTenant(ctx context.Context, tenantID, actorID int64, resourceType string, resourceID int64, action, note string, includeItems bool) error { + action = strings.ToLower(strings.TrimSpace(action)) + if action != "approve" && action != "reject" { + return fmt.Errorf("action must be approve or reject") + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + actualTenant, version, current, err := getResourceReviewStateTx(ctx, tx, s.mode, resourceType, resourceID) + if err != nil { + return err + } + if actualTenant != tenantID { + return ErrTenantForbidden + } + if current != "tenant_pending" && current != "tenant_rejected" { + return fmt.Errorf("%w: expected tenant_pending, got %s", ErrApprovalState, current) + } + newStatus := "tenant_rejected" + if action == "approve" { + newStatus = "platform_pending" + } + if err := updateReviewStateTx(ctx, tx, resourceType, resourceID, newStatus, false, note); err != nil { + return err + } + if err := insertApprovalEventTx(ctx, tx, tenantID, resourceType, resourceID, version, "tenant", action, actorID, note); err != nil { + return err + } + if resourceType == "live_qr" && includeItems { + rows, err := tx.QueryContext(ctx, `SELECT id,content_version FROM live_qr_items WHERE live_qr_id=?`, resourceID) + if err != nil { + return err + } + type itemVersion struct{ id, version int64 } + items := []itemVersion{} + for rows.Next() { + var item itemVersion + if err := rows.Scan(&item.id, &item.version); err != nil { + rows.Close() + return err + } + items = append(items, item) + } + if err := rows.Close(); err != nil { + return err + } + for _, item := range items { + if err := updateReviewStateTx(ctx, tx, "live_qr_item", item.id, newStatus, false, note); err != nil { + return err + } + if err := insertApprovalEventTx(ctx, tx, tenantID, "live_qr_item", item.id, item.version, "tenant", action, actorID, note); err != nil { + return err + } + } + } + return tx.Commit() +} + +func (s *Store) ReviewResourcePlatform(ctx context.Context, actorID int64, resourceType string, resourceID int64, action, note string, includeItems bool) error { + action = strings.ToLower(strings.TrimSpace(action)) + if action != "approve" && action != "reject" { + return fmt.Errorf("action must be approve or reject") + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + tenantID, version, current, err := getResourceReviewStateTx(ctx, tx, s.mode, resourceType, resourceID) + if err != nil { + return err + } + if current != "platform_pending" && current != "platform_rejected" { + return fmt.Errorf("%w: expected platform_pending, got %s", ErrApprovalState, current) + } + newStatus := "platform_rejected" + approved := false + if action == "approve" { + newStatus = "approved" + approved = true + } + if err := updateReviewStateTx(ctx, tx, resourceType, resourceID, newStatus, approved, note); err != nil { + return err + } + if err := insertApprovalEventTx(ctx, tx, tenantID, resourceType, resourceID, version, "platform", action, actorID, note); err != nil { + return err + } + if resourceType == "live_qr" && includeItems { + rows, err := tx.QueryContext(ctx, `SELECT id,content_version,approval_status FROM live_qr_items WHERE live_qr_id=?`, resourceID) + if err != nil { + return err + } + type itemState struct { + id, version int64 + status string + } + items := []itemState{} + for rows.Next() { + var item itemState + if err := rows.Scan(&item.id, &item.version, &item.status); err != nil { + rows.Close() + return err + } + items = append(items, item) + } + if err := rows.Close(); err != nil { + return err + } + for _, item := range items { + if item.status != "platform_pending" && item.status != "platform_rejected" { + continue + } + if err := updateReviewStateTx(ctx, tx, "live_qr_item", item.id, newStatus, approved, note); err != nil { + return err + } + if err := insertApprovalEventTx(ctx, tx, tenantID, "live_qr_item", item.id, item.version, "platform", action, actorID, note); err != nil { + return err + } + } + } + return tx.Commit() +} + +func (s *Store) ListApprovalQueue(ctx context.Context, tenantID int64, stage string, platform bool) ([]model.ApprovalQueueItem, error) { + stage = strings.TrimSpace(stage) + if stage == "" { + if platform { + stage = "platform_pending" + } else { + stage = "tenant_pending" + } + } + whereTenant := "" + args := []any{stage} + if !platform { + whereTenant = " AND x.tenant_id=?" + args = append(args, tenantID) + } + query := `SELECT x.tenant_id,t.name,x.resource_type,x.resource_id,x.code,x.title,x.approval_status,x.content_version,x.owner_account_id,x.updated_at FROM (` + + `SELECT tenant_id,'short_link' resource_type,id resource_id,code,title,approval_status,content_version,owner_account_id,updated_at FROM short_links ` + + `UNION ALL SELECT tenant_id,'live_qr' resource_type,id resource_id,code,title,approval_status,content_version,owner_account_id,updated_at FROM live_qrs` + + `) x JOIN tenants t ON t.id=x.tenant_id WHERE x.approval_status=?` + whereTenant + ` ORDER BY x.updated_at ASC LIMIT 500` + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []model.ApprovalQueueItem{} + for rows.Next() { + var item model.ApprovalQueueItem + var owner sql.NullInt64 + if err := rows.Scan(&item.TenantID, &item.TenantName, &item.ResourceType, &item.ResourceID, &item.Code, &item.Title, &item.ApprovalStatus, &item.ContentVersion, &owner, &item.UpdatedAt); err != nil { + return nil, err + } + if owner.Valid { + item.OwnerAccountID = owner.Int64 + } + out = append(out, item) + } + return out, rows.Err() +} + +func (s *Store) ListApprovalEvents(ctx context.Context, tenantID int64, resourceType string, resourceID int64) ([]model.ApprovalEvent, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id,tenant_id,resource_type,resource_id,content_version,stage,action,actor_account_id,note,created_at FROM approval_events WHERE tenant_id=? AND resource_type=? AND resource_id=? ORDER BY id DESC`, tenantID, resourceType, resourceID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []model.ApprovalEvent{} + for rows.Next() { + var e model.ApprovalEvent + if err := rows.Scan(&e.ID, &e.TenantID, &e.ResourceType, &e.ResourceID, &e.ContentVersion, &e.Stage, &e.Action, &e.ActorAccountID, &e.Note, &e.CreatedAt); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +func (s *Store) hydrateLiveWorkspace(ctx context.Context, live *model.LiveQR) (*model.LiveQRWorkspace, error) { + var w model.LiveQRWorkspace + w.LiveQR = *live + if err := s.db.QueryRowContext(ctx, `SELECT tenant_id,content_version,approved_version FROM live_qrs WHERE id=?`, live.ID).Scan(&w.TenantID, &w.ContentVersion, &w.ApprovedVersion); err != nil { + return nil, err + } + return &w, nil +} + +func (s *Store) GetLiveQRForTenant(ctx context.Context, id, tenantID int64) (*model.LiveQRWorkspace, error) { + live, err := scanLive(s.db.QueryRowContext(ctx, liveSelectSQL()+` WHERE id=? AND tenant_id=?`, id, tenantID)) + if err != nil { + return nil, err + } + return s.hydrateLiveWorkspace(ctx, live) +} + +func (s *Store) ListLiveQRsForTenant(ctx context.Context, tenantID int64, q string, limit, offset int) ([]model.LiveQRWorkspace, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + query := liveSelectSQL() + ` WHERE tenant_id=?` + args := []any{tenantID} + if strings.TrimSpace(q) != "" { + like := "%" + strings.TrimSpace(q) + "%" + query += ` AND (code LIKE ? OR title LIKE ? OR description LIKE ? OR approval_status LIKE ?)` + args = append(args, like, like, like, like) + } + query += ` ORDER BY id DESC LIMIT ? OFFSET ?` + args = append(args, limit, offset) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + lives := []*model.LiveQR{} + for rows.Next() { + live, err := scanLive(rows) + if err != nil { + return nil, err + } + lives = append(lives, live) + } + if err := rows.Close(); err != nil { + return nil, err + } + out := make([]model.LiveQRWorkspace, 0, len(lives)) + for _, live := range lives { + w, err := s.hydrateLiveWorkspace(ctx, live) + if err != nil { + return nil, err + } + out = append(out, *w) + } + return out, nil +} + +func (s *Store) BindLiveQRToTenantAndReset(ctx context.Context, liveID, tenantID int64, created bool) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if created { + if _, err := tx.ExecContext(ctx, `UPDATE live_qrs SET tenant_id=?,approval_status='tenant_pending',content_version=1,approved_version=0,approved_at=NULL,reviewed_at=NULL,review_note=NULL,updated_at=? WHERE id=?`, tenantID, now(), liveID); err != nil { + return err + } + } else { + if _, err := tx.ExecContext(ctx, `UPDATE live_qrs SET tenant_id=?,approval_status='tenant_pending',content_version=content_version+1,approved_version=0,approved_at=NULL,reviewed_at=NULL,review_note=NULL,updated_at=? WHERE id=? AND tenant_id=?`, tenantID, now(), liveID, tenantID); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, `UPDATE live_qr_items SET approval_status='tenant_pending',content_version=content_version+1,approved_version=0,approved_at=NULL,reviewed_at=NULL,review_note=NULL,updated_at=? WHERE live_qr_id=?`, now(), liveID); err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) BindLiveQRItemAndReset(ctx context.Context, itemID, liveID, tenantID int64, created bool) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + var actualTenant int64 + if err := tx.QueryRowContext(ctx, `SELECT tenant_id FROM live_qrs WHERE id=?`, liveID).Scan(&actualTenant); err != nil { + return err + } + if actualTenant != tenantID { + return ErrTenantForbidden + } + if created { + if _, err := tx.ExecContext(ctx, `UPDATE live_qr_items SET approval_status='tenant_pending',content_version=1,approved_version=0,approved_at=NULL,reviewed_at=NULL,review_note=NULL,updated_at=? WHERE id=? AND live_qr_id=?`, now(), itemID, liveID); err != nil { + return err + } + } else { + if _, err := tx.ExecContext(ctx, `UPDATE live_qr_items SET approval_status='tenant_pending',content_version=content_version+1,approved_version=0,approved_at=NULL,reviewed_at=NULL,review_note=NULL,updated_at=? WHERE id=? AND live_qr_id=?`, now(), itemID, liveID); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, `UPDATE live_qrs SET approval_status='tenant_pending',content_version=content_version+1,approved_version=0,approved_at=NULL,reviewed_at=NULL,review_note=NULL,updated_at=? WHERE id=? AND tenant_id=?`, now(), liveID, tenantID); err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) GetLiveTenantID(ctx context.Context, liveID int64) (int64, error) { + var tenantID int64 + if err := s.db.QueryRowContext(ctx, `SELECT tenant_id FROM live_qrs WHERE id=?`, liveID).Scan(&tenantID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, ErrNotFound + } + return 0, err + } + return tenantID, nil +} + +func (s *Store) IncrementTenantVisitForLive(ctx context.Context, tenantID int64) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + plan, _, err := planAndSubscriptionTx(ctx, tx, s.mode, tenantID) + if err != nil { + return err + } + period := monthKey(now()) + if err := ensureUsageTx(ctx, tx, tenantID, period); err != nil { + return err + } + var used int64 + q := `SELECT visits FROM tenant_usage_monthly WHERE tenant_id=? AND period_key=?` + if s.mode == "mysql" { + q += ` FOR UPDATE` + } + if err := tx.QueryRowContext(ctx, q, tenantID, period).Scan(&used); err != nil { + return err + } + if plan.MonthlyVisits > 0 && used >= plan.MonthlyVisits { + return ErrQuotaExceeded + } + if _, err := tx.ExecContext(ctx, `UPDATE tenant_usage_monthly SET visits=visits+1,updated_at=? WHERE tenant_id=? AND period_key=?`, now(), tenantID, period); err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) CountTenantResources(ctx context.Context, tenantID int64) (map[string]int64, error) { + out := map[string]int64{} + for key, query := range map[string]string{ + "short_links": `SELECT COUNT(*) FROM short_links WHERE tenant_id=?`, + "live_qrs": `SELECT COUNT(*) FROM live_qrs WHERE tenant_id=?`, + "members": `SELECT COUNT(*) FROM tenant_members WHERE tenant_id=? AND status='active'`, + } { + var count int64 + if err := s.db.QueryRowContext(ctx, query, tenantID).Scan(&count); err != nil { + return nil, err + } + out[key] = count + } + return out, nil +} + +func (s *Store) ApprovalEventsByResource(ctx context.Context, tenantID int64, resourceType string, resourceID int64) ([]model.ApprovalEvent, error) { + return s.ListApprovalEvents(ctx, tenantID, resourceType, resourceID) +} + +func sortedTargetIDs(items []model.ShortLinkTarget) []int64 { + ids := make([]int64, 0, len(items)) + for _, item := range items { + ids = append(ids, item.ID) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids +} + +var _ = tenantCanWrite +var _ = tenantCanReview +var _ = sortedTargetIDs + +func (s *Store) ListAllTenants(ctx context.Context, q string, limit, offset int) ([]model.Tenant, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + query := tenantSelectSQL + args := []any{} + if strings.TrimSpace(q) != "" { + like := "%" + strings.TrimSpace(q) + "%" + query += ` WHERE slug LIKE ? OR name LIKE ? OR status LIKE ?` + args = append(args, like, like, like) + } + query += ` ORDER BY id DESC LIMIT ? OFFSET ?` + args = append(args, limit, offset) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []model.Tenant{} + for rows.Next() { + t, err := scanTenant(rows) + if err != nil { + return nil, err + } + out = append(out, *t) + } + return out, rows.Err() +} + +func (s *Store) ResourceTenantID(ctx context.Context, resourceType string, resourceID int64) (int64, error) { + table, parent, err := resourceTable(resourceType) + if err != nil { + return 0, err + } + var tenantID int64 + if parent == "" { + err = s.db.QueryRowContext(ctx, fmt.Sprintf(`SELECT tenant_id FROM %s WHERE id=?`, table), resourceID).Scan(&tenantID) + } else { + err = s.db.QueryRowContext(ctx, fmt.Sprintf(`SELECT l.tenant_id FROM %s i JOIN %s l ON l.id=i.live_qr_id WHERE i.id=?`, table, parent), resourceID).Scan(&tenantID) + } + if errors.Is(err, sql.ErrNoRows) { + return 0, ErrNotFound + } + return tenantID, err +} diff --git a/internal/store/store.go b/internal/store/store.go index 245b422..e9454a3 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -376,7 +376,15 @@ func (s *Store) OverviewForAccount(ctx context.Context, accountID int64, isAdmin func (s *Store) CreateShortLink(ctx context.Context, in *model.ShortLink) (*model.ShortLink, error) { normalizeShortLink(in) - res, err := s.db.ExecContext(ctx, `INSERT INTO short_links(owner_account_id,code,title,target_url,status,approval_status,redirect_type,starts_at,expires_at,max_visits,fallback_url,remark,qr_style,qr_foreground,qr_background,qr_logo_url) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, nullInt64(in.OwnerAccountID), in.Code, in.Title, in.TargetURL, in.Status, "pending", in.RedirectType, in.StartsAt, in.ExpiresAt, in.MaxVisits, nullString(in.FallbackURL), nullString(in.Remark), in.QRStyle, in.QRForeground, in.QRBackground, nullString(in.QRLogoURL)) + var tenantID any + if in.OwnerAccountID > 0 { + tenant, _, err := s.EnsurePersonalTenant(ctx, in.OwnerAccountID) + if err != nil { + return nil, err + } + tenantID = tenant.ID + } + res, err := s.db.ExecContext(ctx, `INSERT INTO short_links(owner_account_id,tenant_id,code,title,target_url,status,approval_status,redirect_type,starts_at,expires_at,max_visits,fallback_url,remark,qr_style,qr_foreground,qr_background,qr_logo_url,content_version,approved_version) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, nullInt64(in.OwnerAccountID), tenantID, in.Code, in.Title, in.TargetURL, in.Status, "tenant_pending", in.RedirectType, in.StartsAt, in.ExpiresAt, in.MaxVisits, nullString(in.FallbackURL), nullString(in.Remark), in.QRStyle, in.QRForeground, in.QRBackground, nullString(in.QRLogoURL), 1, 0) if err != nil { return nil, err } @@ -562,7 +570,15 @@ func scanShort(scanner interface{ Scan(dest ...any) error }) (*model.ShortLink, func (s *Store) CreateLiveQR(ctx context.Context, in *model.LiveQR) (*model.LiveQR, error) { normalizeLiveQR(in) - res, err := s.db.ExecContext(ctx, `INSERT INTO live_qrs(owner_account_id,code,title,description,status,approval_status,rotation_strategy,guide_title,guide_text,fallback_url,qr_style,qr_foreground,qr_background,qr_logo_url) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, nullInt64(in.OwnerAccountID), in.Code, in.Title, nullString(in.Description), in.Status, "pending", in.RotationStrategy, in.GuideTitle, in.GuideText, nullString(in.FallbackURL), in.QRStyle, in.QRForeground, in.QRBackground, nullString(in.QRLogoURL)) + var tenantID any + if in.OwnerAccountID > 0 { + tenant, _, err := s.EnsurePersonalTenant(ctx, in.OwnerAccountID) + if err != nil { + return nil, err + } + tenantID = tenant.ID + } + res, err := s.db.ExecContext(ctx, `INSERT INTO live_qrs(owner_account_id,tenant_id,code,title,description,status,approval_status,rotation_strategy,guide_title,guide_text,fallback_url,qr_style,qr_foreground,qr_background,qr_logo_url,content_version,approved_version) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, nullInt64(in.OwnerAccountID), tenantID, in.Code, in.Title, nullString(in.Description), in.Status, "tenant_pending", in.RotationStrategy, in.GuideTitle, in.GuideText, nullString(in.FallbackURL), in.QRStyle, in.QRForeground, in.QRBackground, nullString(in.QRLogoURL), 1, 0) if err != nil { return nil, err } diff --git a/migrations/006_saas_tenants_subscriptions_routing.sql b/migrations/006_saas_tenants_subscriptions_routing.sql new file mode 100644 index 0000000..757b0ac --- /dev/null +++ b/migrations/006_saas_tenants_subscriptions_routing.sql @@ -0,0 +1,195 @@ +CREATE TABLE IF NOT EXISTS tenants ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(128) NOT NULL, + name VARCHAR(255) NOT NULL, + kind VARCHAR(32) NOT NULL DEFAULT 'organization', + status VARCHAR(32) NOT NULL DEFAULT 'active', + owner_account_id BIGINT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenants_slug(slug), + KEY idx_tenants_owner(owner_account_id), + CONSTRAINT fk_tenants_owner FOREIGN KEY(owner_account_id) REFERENCES admin_accounts(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tenant_members ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + account_id BIGINT NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'member', + status VARCHAR(32) NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_members_account(tenant_id,account_id), + KEY idx_tenant_members_account_status(account_id,status), + CONSTRAINT fk_tenant_members_tenant FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + CONSTRAINT fk_tenant_members_account FOREIGN KEY(account_id) REFERENCES admin_accounts(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS plans ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + description TEXT NOT NULL, + price_monthly_cents BIGINT NOT NULL DEFAULT 0, + currency VARCHAR(16) NOT NULL DEFAULT 'CNY', + max_members BIGINT NOT NULL DEFAULT 0, + max_short_links BIGINT NOT NULL DEFAULT 0, + max_live_qrs BIGINT NOT NULL DEFAULT 0, + max_targets_per_link BIGINT NOT NULL DEFAULT 0, + monthly_visits BIGINT NOT NULL DEFAULT 0, + features_json LONGTEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_plans_code(code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tenant_subscriptions ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + plan_id BIGINT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + provider VARCHAR(64) NOT NULL DEFAULT 'manual', + external_customer_id VARCHAR(255) NULL, + external_subscription_id VARCHAR(255) NULL, + current_period_start DATETIME NULL, + current_period_end DATETIME NULL, + cancel_at_period_end TINYINT(1) NOT NULL DEFAULT 0, + trial_ends_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_subscriptions_tenant(tenant_id), + KEY idx_tenant_subscriptions_status(status), + CONSTRAINT fk_subscriptions_tenant FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + CONSTRAINT fk_subscriptions_plan FOREIGN KEY(plan_id) REFERENCES plans(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS subscription_change_requests ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + from_plan_id BIGINT NOT NULL, + to_plan_id BIGINT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + pending_guard VARCHAR(32) NULL, + note TEXT NOT NULL, + review_note TEXT NOT NULL, + requested_by BIGINT NOT NULL, + reviewed_by BIGINT NULL, + reviewed_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_subscription_request_pending(tenant_id,pending_guard), + KEY idx_subscription_requests_status(status,created_at), + CONSTRAINT fk_subscription_requests_tenant FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + CONSTRAINT fk_subscription_requests_from_plan FOREIGN KEY(from_plan_id) REFERENCES plans(id), + CONSTRAINT fk_subscription_requests_to_plan FOREIGN KEY(to_plan_id) REFERENCES plans(id), + CONSTRAINT fk_subscription_requests_requested_by FOREIGN KEY(requested_by) REFERENCES admin_accounts(id), + CONSTRAINT fk_subscription_requests_reviewed_by FOREIGN KEY(reviewed_by) REFERENCES admin_accounts(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tenant_usage_monthly ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + period_key VARCHAR(7) NOT NULL, + visits BIGINT NOT NULL DEFAULT 0, + short_links_created BIGINT NOT NULL DEFAULT 0, + live_qrs_created BIGINT NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_usage_period(tenant_id,period_key), + CONSTRAINT fk_tenant_usage_tenant FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS approval_events ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + resource_type VARCHAR(32) NOT NULL, + resource_id BIGINT NOT NULL, + content_version BIGINT NOT NULL, + stage VARCHAR(32) NOT NULL, + action VARCHAR(32) NOT NULL, + actor_account_id BIGINT NOT NULL, + note TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_approval_events_resource(resource_type,resource_id,content_version,created_at), + KEY idx_approval_events_tenant(tenant_id,created_at), + CONSTRAINT fk_approval_events_tenant FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + CONSTRAINT fk_approval_events_actor FOREIGN KEY(actor_account_id) REFERENCES admin_accounts(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS short_link_targets ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT NOT NULL, + short_link_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL DEFAULT '', + target_url TEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + weight INT NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 100, + starts_at DATETIME NULL, + expires_at DATETIME NULL, + max_hits BIGINT NOT NULL DEFAULT 0, + hit_count BIGINT NOT NULL DEFAULT 0, + health_status VARCHAR(32) NOT NULL DEFAULT 'unknown', + last_hit_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_short_targets_select(short_link_id,status,health_status,sort_order,id), + KEY idx_short_targets_tenant(tenant_id,short_link_id), + CONSTRAINT fk_short_targets_tenant FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + CONSTRAINT fk_short_targets_link FOREIGN KEY(short_link_id) REFERENCES short_links(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE short_links + ADD COLUMN tenant_id BIGINT NULL, + ADD COLUMN routing_strategy VARCHAR(32) NOT NULL DEFAULT 'single', + ADD COLUMN current_target_cursor BIGINT NOT NULL DEFAULT 0, + ADD COLUMN content_version BIGINT NOT NULL DEFAULT 1, + ADD COLUMN approved_version BIGINT NOT NULL DEFAULT 0, + ADD KEY idx_short_links_tenant_created(tenant_id,created_at), + ADD KEY idx_short_links_tenant_approval(tenant_id,approval_status,updated_at); +ALTER TABLE live_qrs + ADD COLUMN tenant_id BIGINT NULL, + ADD COLUMN content_version BIGINT NOT NULL DEFAULT 1, + ADD COLUMN approved_version BIGINT NOT NULL DEFAULT 0, + ADD KEY idx_live_qrs_tenant_created(tenant_id,created_at), + ADD KEY idx_live_qrs_tenant_approval(tenant_id,approval_status,updated_at); +ALTER TABLE live_qr_items + ADD COLUMN content_version BIGINT NOT NULL DEFAULT 1, + ADD COLUMN approved_version BIGINT NOT NULL DEFAULT 0; +ALTER TABLE visit_logs + ADD COLUMN tenant_id BIGINT NULL, + ADD COLUMN target_id BIGINT NULL, + ADD KEY idx_visit_tenant_time(tenant_id,created_at), + ADD KEY idx_visit_target_time(target_id,created_at); +ALTER TABLE audit_logs + ADD COLUMN tenant_id BIGINT NULL, + ADD KEY idx_audit_tenant_time(tenant_id,created_at); + +INSERT IGNORE INTO plans(code,name,description,price_monthly_cents,currency,max_members,max_short_links,max_live_qrs,max_targets_per_link,monthly_visits,features_json,status) VALUES +('free','Free','适合个人和试用',0,'CNY',3,100,20,3,10000,'{"analytics":true,"approval":true}','active'), +('pro','Pro','适合小团队运营',4900,'CNY',10,2000,300,20,500000,'{"analytics":true,"approval":true,"routing":true}','active'), +('business','Business','适合多成员业务团队',19900,'CNY',50,20000,3000,100,5000000,'{"analytics":true,"approval":true,"routing":true,"audit":true}','active'), +('enterprise','Enterprise','私有化与定制额度',0,'CNY',0,0,0,0,0,'{"analytics":true,"approval":true,"routing":true,"audit":true,"custom":true}','active'); + +INSERT IGNORE INTO tenants(slug,name,kind,status,owner_account_id) +SELECT CONCAT('personal-',id),CASE WHEN TRIM(name)='' THEN COALESCE(email,'Personal Workspace') ELSE CONCAT(name,' Workspace') END,'personal','active',id FROM admin_accounts; +INSERT IGNORE INTO tenant_members(tenant_id,account_id,role,status) +SELECT t.id,t.owner_account_id,'owner','active' FROM tenants t WHERE t.kind='personal' AND t.owner_account_id IS NOT NULL; +INSERT IGNORE INTO tenants(slug,name,kind,status,owner_account_id) +SELECT 'platform-default','Platform Workspace','organization','active',id FROM admin_accounts ORDER BY CASE WHEN role='admin' THEN 0 ELSE 1 END,id LIMIT 1; +INSERT IGNORE INTO tenant_members(tenant_id,account_id,role,status) +SELECT t.id,t.owner_account_id,'owner','active' FROM tenants t WHERE t.slug='platform-default' AND t.owner_account_id IS NOT NULL; +INSERT IGNORE INTO tenant_subscriptions(tenant_id,plan_id,status,provider,current_period_start) +SELECT t.id,p.id,'active','manual',CURRENT_TIMESTAMP FROM tenants t JOIN plans p ON p.code='free'; + +UPDATE short_links s JOIN tenants t ON t.owner_account_id=s.owner_account_id AND t.kind='personal' SET s.tenant_id=t.id WHERE s.tenant_id IS NULL AND s.owner_account_id IS NOT NULL; +UPDATE short_links s JOIN tenants t ON t.slug='platform-default' SET s.tenant_id=t.id WHERE s.tenant_id IS NULL; +UPDATE live_qrs l JOIN tenants t ON t.owner_account_id=l.owner_account_id AND t.kind='personal' SET l.tenant_id=t.id WHERE l.tenant_id IS NULL AND l.owner_account_id IS NOT NULL; +UPDATE live_qrs l JOIN tenants t ON t.slug='platform-default' SET l.tenant_id=t.id WHERE l.tenant_id IS NULL; +UPDATE short_links SET approved_version=CASE WHEN approval_status='approved' THEN content_version ELSE 0 END, + approval_status=CASE WHEN approval_status='pending' THEN 'tenant_pending' WHEN approval_status='rejected' THEN 'tenant_rejected' ELSE approval_status END; +UPDATE live_qrs SET approved_version=CASE WHEN approval_status='approved' THEN content_version ELSE 0 END, + approval_status=CASE WHEN approval_status='pending' THEN 'tenant_pending' WHEN approval_status='rejected' THEN 'tenant_rejected' ELSE approval_status END; +UPDATE live_qr_items SET approved_version=CASE WHEN approval_status='approved' THEN content_version ELSE 0 END, + approval_status=CASE WHEN approval_status='pending' THEN 'tenant_pending' WHEN approval_status='rejected' THEN 'tenant_rejected' ELSE approval_status END; diff --git a/web/static/saas.css b/web/static/saas.css new file mode 100644 index 0000000..26a8994 --- /dev/null +++ b/web/static/saas.css @@ -0,0 +1,13 @@ +.saas-workspace-switcher{display:grid;gap:6px;margin:0 14px 12px;padding:12px;border:1px solid var(--line,#e5e7eb);border-radius:12px;background:var(--panel,#fff)} +.saas-workspace-switcher span{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted,#64748b)} +.saas-workspace-switcher select{width:100%;min-width:0;border:0;background:transparent;color:inherit;font:inherit;font-weight:650;outline:none} +.saas-page{display:grid;gap:18px}.saas-grid{display:grid;gap:18px}.saas-grid.two{grid-template-columns:repeat(2,minmax(0,1fr))}.saas-card{padding:20px}.saas-card.selected{outline:2px solid var(--accent,#2563eb)} +.saas-card-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px}.saas-card-head h2,.saas-card h2{margin:4px 0 0;font-size:20px}.saas-card-head small,.saas-card small{display:block;color:var(--muted,#64748b)} +.saas-definition{display:grid;gap:10px;margin:0}.saas-definition div{display:flex;justify-content:space-between;gap:16px}.saas-definition dt{color:var(--muted,#64748b)}.saas-definition dd{margin:0;font-weight:650;text-align:right} +.saas-tenant-list{display:grid;gap:8px}.saas-tenant-item{display:flex;align-items:center;justify-content:space-between;gap:14px;width:100%;padding:12px 14px;border:1px solid var(--line,#e5e7eb);border-radius:10px;background:transparent;color:inherit;text-align:left}.saas-tenant-item.active{border-color:var(--accent,#2563eb);box-shadow:0 0 0 1px var(--accent,#2563eb)}.saas-tenant-item span{color:var(--muted,#64748b);font-size:12px} +.saas-table-wrap{overflow:auto}.saas-table-wrap table{width:100%;border-collapse:collapse}.saas-table-wrap th,.saas-table-wrap td{padding:12px;border-bottom:1px solid var(--line,#e5e7eb);text-align:left;vertical-align:middle;white-space:nowrap}.saas-table-wrap td small{display:block;margin-top:3px}.saas-table-wrap .danger{margin-left:6px} +.saas-plan-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.saas-plan-grid ul{padding-left:18px;color:var(--muted,#64748b);line-height:1.9}.saas-price{display:block;margin:14px 0;font-size:18px} +.saas-quota-row{display:grid;gap:7px;margin-top:14px}.saas-quota-row>div:first-child{display:flex;justify-content:space-between;gap:12px;font-size:13px}.saas-quota-row span{color:var(--muted,#64748b)}.saas-meter{height:7px;border-radius:999px;background:var(--line,#e5e7eb);overflow:hidden}.saas-meter i{display:block;height:100%;border-radius:inherit;background:var(--accent,#2563eb)} +.saas-target-editor{display:grid;gap:16px}.saas-target-head,.saas-target-row{display:grid;grid-template-columns:1fr 2.2fr .8fr .7fr .8fr .9fr 38px;gap:8px;align-items:center}.saas-target-head{padding:0 4px;color:var(--muted,#64748b);font-size:12px}.saas-target-row input,.saas-target-row select{min-width:0;width:100%;padding:9px;border:1px solid var(--line,#e5e7eb);border-radius:8px;background:var(--panel,#fff);color:inherit}.modal-card.wide{width:min(1180px,calc(100vw - 32px))} +@media(max-width:1050px){.saas-plan-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.saas-target-head{display:none}.saas-target-row{grid-template-columns:1fr 1fr}.saas-target-row [data-field="url"]{grid-column:1/-1}.saas-target-row [data-remove-target]{justify-self:end}} +@media(max-width:760px){.saas-grid.two,.saas-plan-grid{grid-template-columns:1fr}.saas-card{padding:15px}.saas-target-row{grid-template-columns:1fr}.saas-target-row [data-field="url"]{grid-column:auto}.saas-table-wrap table{min-width:720px}} diff --git a/web/static/saas.js b/web/static/saas.js new file mode 100644 index 0000000..0927657 --- /dev/null +++ b/web/static/saas.js @@ -0,0 +1,329 @@ +(() => { + 'use strict'; + + const nativeFetch = window.fetch.bind(window); + const tenantStorageKey = 'asl_tenant_id'; + let bootstrap = null; + + function currentTenantID() { + return localStorage.getItem(tenantStorageKey) || ''; + } + + window.fetch = (input, init = {}) => { + const url = typeof input === 'string' ? input : (input && input.url) || ''; + if (!url.includes('/api/admin/')) return nativeFetch(input, init); + const headers = new Headers(init.headers || (typeof input !== 'string' ? input.headers : undefined)); + const tenantID = currentTenantID(); + if (tenantID) headers.set('X-Tenant-ID', tenantID); + return nativeFetch(input, { ...init, headers, credentials: init.credentials || 'same-origin' }); + }; + + const $ = (selector, root = document) => root.querySelector(selector); + const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; + const esc = value => String(value ?? '').replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); + const isZh = () => (localStorage.getItem('asl_lang') || navigator.language || 'zh').toLowerCase().startsWith('zh'); + const text = (zh, en) => isZh() ? zh : en; + const content = () => $('#content'); + + async function api(path, options = {}) { + const headers = new Headers(options.headers || {}); + if (!(options.body instanceof FormData) && options.body !== undefined) headers.set('Content-Type', 'application/json'); + const response = await window.fetch(path, { ...options, headers }); + const data = await response.json().catch(() => ({})); + if (!response.ok || data.ok === false) throw new Error(data.message || data.error || `HTTP ${response.status}`); + return data; + } + + function toast(message, error = false) { + const node = document.createElement('div'); + node.className = `toast${error ? ' error' : ''}`; + node.textContent = message; + document.body.appendChild(node); + setTimeout(() => node.remove(), 3000); + } + + async function loadBootstrap(force = false) { + if (bootstrap && !force) return bootstrap; + bootstrap = await api('/api/admin/saas/bootstrap'); + const selected = String(bootstrap.current_tenant?.id || ''); + if (selected && currentTenantID() !== selected) localStorage.setItem(tenantStorageKey, selected); + updateWorkspaceSwitcher(); + updateSaaSNav(); + return bootstrap; + } + + function updateWorkspaceSwitcher() { + let host = $('#saasWorkspaceSwitcher'); + if (!host) { + host = document.createElement('label'); + host.id = 'saasWorkspaceSwitcher'; + host.className = 'saas-workspace-switcher'; + host.innerHTML = `${text('工作空间', 'Workspace')}`; + const brand = $('.sidebar .brand'); + brand?.insertAdjacentElement('afterend', host); + $('select', host)?.addEventListener('change', async event => { + localStorage.setItem(tenantStorageKey, event.target.value); + bootstrap = null; + try { + await loadBootstrap(true); + location.reload(); + } catch (err) { + toast(err.message, true); + } + }); + } + const select = $('select', host); + if (!select || !bootstrap) return; + select.innerHTML = (bootstrap.tenants || []).map(access => { + const tenant = access.tenant || access.Tenant || access; + const role = access.role || ''; + return ``; + }).join(''); + } + + function updateSaaSNav() { + if (!bootstrap || $('#saasNavGroup')) return; + const nav = $('.sidebar nav'); + if (!nav) return; + const group = document.createElement('div'); + group.id = 'saasNavGroup'; + group.className = 'nav-group'; + group.innerHTML = ` + + + + + + ${bootstrap.platform_admin ? `` : ''}`; + const management = $$('.nav-group', nav).find(item => item.querySelector('.admin-only')); + if (management) nav.insertBefore(group, management); else nav.appendChild(group); + } + + function setPage(title, description, view) { + $('#pageTitle').textContent = title; + $('#pageDesc').textContent = description; + $$('.nav').forEach(item => item.classList.toggle('active', item.dataset.saasView === view)); + const create = $('#createBtn'); + if (create) create.style.display = 'none'; + const menu = $('#createMenu'); + if (menu) menu.style.display = 'none'; + } + + function quotaBar(label, used, limit) { + const unlimited = Number(limit || 0) === 0; + const ratio = unlimited ? 0 : Math.min(100, Math.round((Number(used || 0) / Number(limit)) * 100)); + return `
${esc(label)}${Number(used || 0).toLocaleString()} / ${unlimited ? '∞' : Number(limit).toLocaleString()}
`; + } + + async function renderWorkspace() { + const data = await loadBootstrap(true); + setPage(text('租户与成员', 'Tenant & members'), text('切换工作空间、维护成员及租户角色。', 'Switch workspaces and manage tenant roles.'), 'workspace'); + let members = []; + if (['owner', 'admin'].includes(data.tenant_role)) { + members = (await api(`/api/admin/saas/tenants/${data.current_tenant.id}/members`)).data || []; + } + content().innerHTML = `
+
+
${text('当前工作空间', 'Current workspace')}

${esc(data.current_tenant.name)}

${esc(data.tenant_role)}
Slug
${esc(data.current_tenant.slug)}
${text('类型', 'Kind')}
${esc(data.current_tenant.kind)}
${text('状态', 'Status')}
${esc(data.current_tenant.status)}
+
${text('可访问工作空间', 'Accessible workspaces')}

${(data.tenants || []).length}

${(data.tenants || []).map(access => { const t = access.tenant; return ``; }).join('')}
+
+
${text('成员权限', 'Member permissions')}

${text('租户成员', 'Tenant members')}

${['owner', 'admin'].includes(data.tenant_role) ? `` : ''}
+ ${members.length ? `
${members.map(m => ``).join('')}
${text('账户', 'Account')}${text('角色', 'Role')}${text('状态', 'Status')}${text('操作', 'Actions')}
${esc(m.name || '-')}${esc(m.email || `#${m.account_id}`)}${esc(m.role)}${esc(m.status)}
` : `
${text('当前角色无成员管理权限。', 'Your role cannot manage members.')}
`} +
+
`; + + $('#saasCreateTenant')?.addEventListener('click', createTenant); + $('#saasAddMember')?.addEventListener('click', addMember); + $$('[data-switch-tenant]').forEach(button => button.addEventListener('click', () => switchTenant(button.dataset.switchTenant))); + $$('[data-edit-member]').forEach(button => button.addEventListener('click', () => editMember(button.dataset.editMember, button.dataset.memberRole, button.dataset.memberStatus))); + $$('[data-remove-member]').forEach(button => button.addEventListener('click', () => removeMember(button.dataset.removeMember))); + } + + async function createTenant() { + const name = prompt(text('工作空间名称', 'Workspace name')); + if (!name?.trim()) return; + try { + const result = await api('/api/admin/saas/tenants', { method: 'POST', body: JSON.stringify({ name: name.trim() }) }); + await switchTenant(result.data.id); + } catch (err) { toast(err.message, true); } + } + + async function switchTenant(id) { + localStorage.setItem(tenantStorageKey, String(id)); + bootstrap = null; + await loadBootstrap(true); + location.reload(); + } + + async function addMember() { + const email = prompt(text('输入系统中已存在账户的邮箱', 'Enter an existing account email')); + if (!email?.trim()) return; + const role = prompt(text('角色:owner / admin / reviewer / member / analyst', 'Role: owner / admin / reviewer / member / analyst'), 'member'); + if (!role) return; + try { + await api(`/api/admin/saas/tenants/${bootstrap.current_tenant.id}/members`, { method: 'POST', body: JSON.stringify({ email: email.trim(), role: role.trim(), status: 'active' }) }); + toast(text('成员已添加', 'Member added')); + await renderWorkspace(); + } catch (err) { toast(err.message, true); } + } + + async function editMember(accountID, oldRole, oldStatus) { + const role = prompt(text('新角色', 'New role'), oldRole); + if (!role) return; + const status = prompt(text('状态:active / disabled', 'Status: active / disabled'), oldStatus); + if (!status) return; + try { + await api(`/api/admin/saas/tenants/${bootstrap.current_tenant.id}/members/${accountID}`, { method: 'PUT', body: JSON.stringify({ role, status }) }); + toast(text('成员权限已更新', 'Member updated')); + await renderWorkspace(); + } catch (err) { toast(err.message, true); } + } + + async function removeMember(accountID) { + if (!confirm(text('确定移除该成员?', 'Remove this member?'))) return; + try { + await api(`/api/admin/saas/tenants/${bootstrap.current_tenant.id}/members/${accountID}`, { method: 'DELETE' }); + toast(text('成员已移除', 'Member removed')); + await renderWorkspace(); + } catch (err) { toast(err.message, true); } + } + + async function renderSubscription() { + const data = await loadBootstrap(true); + const [plansResult, requestsResult] = await Promise.all([api('/api/admin/saas/plans'), api('/api/admin/saas/subscription/requests')]); + const quota = data.quota; + const plan = quota.plan; + setPage(text('订阅与额度', 'Subscription & quota'), text('查看套餐、当前用量并提交套餐变更申请。', 'Review limits and request a plan change.'), 'subscription'); + content().innerHTML = `
+
${text('当前套餐', 'Current plan')}

${esc(plan.name)}

${esc(quota.subscription.status)}

${esc(plan.description)}

${plan.price_monthly_cents ? `¥${(plan.price_monthly_cents / 100).toFixed(2)} / ${text('月', 'month')}` : text('免费或人工报价', 'Free or custom')}
+

${text('本周期使用量', 'Current usage')}

${quotaBar(text('成员', 'Members'), quota.members_used, plan.max_members)}${quotaBar(text('短链', 'Short links'), quota.short_links_used, plan.max_short_links)}${quotaBar(text('活码', 'Live QR'), quota.live_qrs_used, plan.max_live_qrs)}${quotaBar(text('月访问量', 'Monthly visits'), quota.monthly_visits_used, plan.monthly_visits)}
+
${(plansResult.data || []).map(item => `
${esc(item.code)}

${esc(item.name)}

${esc(item.description)}

  • ${text('成员', 'Members')}: ${item.max_members || '∞'}
  • ${text('短链', 'Links')}: ${item.max_short_links || '∞'}
  • ${text('每条目标', 'Targets/link')}: ${item.max_targets_per_link || '∞'}
  • ${text('月访问', 'Monthly visits')}: ${item.monthly_visits || '∞'}
${item.id !== plan.id ? `` : `${text('当前套餐', 'Current')}`}
`).join('')}
+

${text('变更记录', 'Change requests')}

${(requestsResult.data || []).map(item => ``).join('') || ``}
${text('套餐', 'Plan')}${text('状态', 'Status')}${text('说明', 'Note')}${text('时间', 'Time')}
${esc(item.from_plan)} → ${esc(item.to_plan)}${esc(item.status)}${esc(item.review_note || item.note || '-')}${new Date(item.created_at).toLocaleString()}
${text('暂无申请', 'No requests')}
+
`; + $$('[data-request-plan]').forEach(button => button.addEventListener('click', async () => { + const note = prompt(text('申请说明(可选)', 'Request note (optional)')) || ''; + try { + await api('/api/admin/saas/subscription/requests', { method: 'POST', body: JSON.stringify({ plan_id: Number(button.dataset.requestPlan), note }) }); + toast(text('套餐申请已提交平台审核', 'Plan request submitted')); + await renderSubscription(); + } catch (err) { toast(err.message, true); } + })); + } + + async function renderRouting() { + await loadBootstrap(true); + setPage(text('流量路由', 'Traffic routing'), text('一个稳定短码对应多个目标,支持轮询、随机、权重、最少使用和 IP Hash。', 'Route one stable code across multiple destinations.'), 'routing'); + const result = await api('/api/admin/short-links?limit=200'); + const items = result.data || []; + content().innerHTML = `
${text('多目标短链', 'Multi-target links')}

${items.length}

${items.map(item => ``).join('') || ``}
${text('短码', 'Code')}${text('标题', 'Title')}${text('策略', 'Strategy')}${text('审批', 'Approval')}${text('操作', 'Actions')}
${esc(item.code)}${esc(item.title || '-')}${esc(item.routing_strategy || 'single')}${esc(item.approval_status)}
${text('暂无短链', 'No short links')}
`; + $$('[data-manage-targets]').forEach(button => button.addEventListener('click', () => openTargets(Number(button.dataset.manageTargets)))); + } + + function targetRow(item = {}) { + return `
`; + } + + async function openTargets(id) { + try { + const result = await api(`/api/admin/short-links/${id}/targets`); + const modal = $('#modal'); + $('#modalTitle').textContent = text('短链目标池', 'Short-link target pool'); + $('#modalBody').innerHTML = `
${text('名称', 'Name')}URL${text('状态', 'Status')}${text('权重', 'Weight')}${text('上限', 'Limit')}${text('健康', 'Health')}
${(result.data || []).map(targetRow).join('')}
`; + $('#saasRoutingStrategy').value = result.routing_strategy || 'single'; + modal.hidden = false; + modal.querySelector('.modal-card')?.classList.add('wide'); + $('#saasAddTarget').onclick = () => $('#saasTargetRows').insertAdjacentHTML('beforeend', targetRow()); + $('#saasTargetRows').onclick = event => event.target.closest('[data-remove-target]')?.closest('.saas-target-row')?.remove(); + $('#saasSaveTargets').onclick = async () => { + const targets = $$('.saas-target-row').map((row, index) => ({ id: Number(row.dataset.targetId || 0), name: $('[data-field="name"]', row).value.trim(), target_url: $('[data-field="url"]', row).value.trim(), status: $('[data-field="status"]', row).value, weight: Number($('[data-field="weight"]', row).value || 1), max_hits: Number($('[data-field="max"]', row).value || 0), health_status: $('[data-field="health"]', row).value, sort_order: (index + 1) * 10 })); + await api(`/api/admin/short-links/${id}/targets`, { method: 'PUT', body: JSON.stringify({ routing_strategy: $('#saasRoutingStrategy').value, targets }) }); + modal.hidden = true; + toast(text('目标池已保存,内容已回到租户待审状态', 'Targets saved and resubmitted')); + await renderRouting(); + }; + } catch (err) { toast(err.message, true); } + } + + function approvalTable(items, platform) { + return `
${items.map(item => ``).join('') || ``}
${text('租户', 'Tenant')}${text('资源', 'Resource')}${text('内容', 'Content')}${text('版本', 'Version')}${text('操作', 'Actions')}
${esc(item.tenant_name)}${esc(item.resource_type)} #${item.resource_id}${esc(item.title || '-')}${esc(item.code || '')}v${item.content_version}
${text('暂无待审内容', 'No pending content')}
`; + } + + async function renderApprovals() { + const data = await loadBootstrap(true); + setPage(text('租户审批', 'Tenant approval'), text('租户审核通过后,内容才会进入平台终审队列。', 'Tenant approval submits content to platform review.'), 'approvals'); + if (!['owner', 'admin', 'reviewer'].includes(data.tenant_role)) { + content().innerHTML = `
${text('当前角色没有审批权限。', 'Your role cannot review content.')}
`; + return; + } + const result = await api('/api/admin/saas/reviews?stage=tenant_pending'); + content().innerHTML = `
tenant_pending

${text('等待租户初审', 'Pending tenant review')}

${(result.data || []).length}
${approvalTable(result.data || [], false)}
`; + bindReviewButtons(renderApprovals); + } + + async function renderPlatform() { + await loadBootstrap(true); + setPage(text('平台终审', 'Platform review'), text('平台总管理员终审租户内容并审批套餐变更。', 'Final content and subscription review.'), 'platform'); + const [reviews, subscriptions, tenants] = await Promise.all([api('/api/admin/saas/platform/reviews?stage=platform_pending'), api('/api/admin/saas/platform/subscription-requests'), api('/api/admin/saas/platform/tenants?limit=100')]); + content().innerHTML = `
platform_pending

${text('内容终审', 'Content final review')}

${(reviews.data || []).length}
${approvalTable(reviews.data || [], true)}
+

${text('套餐申请', 'Plan requests')}

${(subscriptions.data || []).filter(item => item.status === 'pending').length}
${(subscriptions.data || []).filter(item => item.status === 'pending').map(item => ``).join('') || ``}
${text('租户', 'Tenant')}${text('变更', 'Change')}${text('说明', 'Note')}${text('操作', 'Actions')}
${esc(item.tenant_name)}${esc(item.from_plan)} → ${esc(item.to_plan)}${esc(item.note || '-')}
${text('暂无待审申请', 'No pending requests')}
+

${text('平台租户', 'Platform tenants')}

${(tenants.data || []).length}
${(tenants.data || []).map(item => `
${esc(item.name)}${esc(item.slug)} · ${esc(item.status)}
`).join('')}
`; + bindReviewButtons(renderPlatform); + $$('[data-subscription-id]').forEach(button => button.addEventListener('click', async () => { + const note = prompt(text('审批意见(可选)', 'Review note (optional)')) || ''; + try { + await api(`/api/admin/saas/platform/subscription-requests/${button.dataset.subscriptionId}`, { method: 'POST', body: JSON.stringify({ action: button.dataset.subscriptionAction, note }) }); + toast(text('订阅申请已处理', 'Subscription request reviewed')); + await renderPlatform(); + } catch (err) { toast(err.message, true); } + })); + } + + function bindReviewButtons(refresh) { + $$('[data-review-resource]').forEach(button => button.addEventListener('click', async () => { + const note = prompt(text('审批意见(驳回时建议填写)', 'Review note')) || ''; + const platform = button.dataset.reviewStage === 'platform'; + const base = platform ? '/api/admin/saas/platform/reviews' : '/api/admin/saas/reviews'; + try { + await api(`${base}/${button.dataset.reviewResource}/${button.dataset.reviewId}`, { method: 'POST', body: JSON.stringify({ action: button.dataset.reviewAction, note, include_items: button.dataset.reviewResource === 'live_qr' }) }); + toast(text('审批状态已更新', 'Review completed')); + await refresh(); + } catch (err) { toast(err.message, true); } + })); + } + + async function renderView(view) { + try { + if (view === 'workspace') return await renderWorkspace(); + if (view === 'routing') return await renderRouting(); + if (view === 'subscription') return await renderSubscription(); + if (view === 'approvals') return await renderApprovals(); + if (view === 'platform') return await renderPlatform(); + } catch (err) { + content().innerHTML = `
${text('加载失败', 'Load failed')}

${esc(err.message)}

`; + toast(err.message, true); + } + } + + document.addEventListener('click', event => { + const button = event.target.closest('[data-saas-view]'); + if (!button) return; + event.preventDefault(); + event.stopImmediatePropagation(); + const view = button.dataset.saasView; + localStorage.setItem('asl_view', `saas:${view}`); + renderView(view); + }, true); + + async function start() { + try { + await loadBootstrap(true); + const saved = localStorage.getItem('asl_view') || ''; + if (saved.startsWith('saas:')) await renderView(saved.slice(5)); + } catch (err) { + console.error('SaaS bootstrap failed', err); + } + } + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); + else start(); +})(); diff --git a/web/templates/admin.html b/web/templates/admin.html index 4974578..2ff7259 100644 --- a/web/templates/admin.html +++ b/web/templates/admin.html @@ -7,6 +7,7 @@ + @@ -81,6 +82,7 @@

Edit

+