mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 19:41:14 +02:00
refactor(channel): drop lark_* tables and queries (remove old path)
The Go cutover (previous commit) moved the lark package entirely onto channel_* and the domain types, leaving the lark_* tables, queries/lark.sql, and the generated db.Lark* models unused. Remove them per the design (§5: replace, do not keep both): migration 125 drops the seven lark_* tables (data already lives in channel_* since migration 124), and queries/lark.sql is deleted + sqlc regenerated, removing the db.Lark* models and lark query methods. The 125 down recreates the authoritative pre-drop schema (bot_union_id, region, per-installation dedup PK, thread-reply columns). Verified on PG17: fresh migrate up ends with lark_* gone + channel_* present; isolated 125 down/up round-trips correctly; go build / vet / gofmt clean; go test -race ./internal/integrations/... and the handler suite pass. MUL-3515 Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
195
server/migrations/125_drop_lark_tables.down.sql
Normal file
195
server/migrations/125_drop_lark_tables.down.sql
Normal file
@@ -0,0 +1,195 @@
|
||||
-- Recreate the lark_* tables dropped by 125_drop_lark_tables.up.sql
|
||||
-- (structure only — the data lives in channel_* after migration 124). This is
|
||||
-- the authoritative pre-drop schema: lark_installation plus the deltas from
|
||||
-- migrations 112 (bot_union_id), 113 (per-installation dedup), 116 (region),
|
||||
-- and 122 (thread-reply columns). Foreign keys are added after all tables
|
||||
-- exist, so table order does not matter.
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lark_binding_token (
|
||||
token_hash text NOT NULL,
|
||||
workspace_id uuid NOT NULL,
|
||||
installation_id uuid NOT NULL,
|
||||
lark_open_id text NOT NULL,
|
||||
expires_at timestamp with time zone NOT NULL,
|
||||
consumed_at timestamp with time zone,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT lark_binding_token_ttl_cap CHECK ((expires_at <= (created_at + '00:15:00'::interval)))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lark_chat_session_binding (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
chat_session_id uuid NOT NULL,
|
||||
installation_id uuid NOT NULL,
|
||||
lark_chat_id text NOT NULL,
|
||||
lark_chat_type text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
last_lark_message_id text,
|
||||
last_lark_thread_id text,
|
||||
CONSTRAINT lark_chat_session_binding_lark_chat_type_check CHECK ((lark_chat_type = ANY (ARRAY['p2p'::text, 'group'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lark_inbound_audit (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
installation_id uuid,
|
||||
lark_chat_id text,
|
||||
event_type text NOT NULL,
|
||||
lark_event_id text,
|
||||
lark_message_id text,
|
||||
drop_reason text NOT NULL,
|
||||
received_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lark_inbound_message_dedup (
|
||||
installation_id uuid NOT NULL,
|
||||
message_id text NOT NULL,
|
||||
received_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
processed_at timestamp with time zone,
|
||||
claim_token uuid DEFAULT gen_random_uuid() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lark_installation (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
workspace_id uuid NOT NULL,
|
||||
agent_id uuid NOT NULL,
|
||||
app_id text NOT NULL,
|
||||
app_secret_encrypted bytea NOT NULL,
|
||||
tenant_key text,
|
||||
bot_open_id text NOT NULL,
|
||||
installer_user_id uuid NOT NULL,
|
||||
status text DEFAULT 'active'::text NOT NULL,
|
||||
ws_lease_token text,
|
||||
ws_lease_expires_at timestamp with time zone,
|
||||
installed_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
bot_union_id text,
|
||||
region text DEFAULT 'feishu'::text NOT NULL,
|
||||
CONSTRAINT lark_installation_region_check CHECK ((region = ANY (ARRAY['feishu'::text, 'lark'::text]))),
|
||||
CONSTRAINT lark_installation_status_check CHECK ((status = ANY (ARRAY['active'::text, 'revoked'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lark_outbound_card_message (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
chat_session_id uuid NOT NULL,
|
||||
task_id uuid,
|
||||
lark_chat_id text NOT NULL,
|
||||
lark_card_message_id text NOT NULL,
|
||||
status text DEFAULT 'pending'::text NOT NULL,
|
||||
last_patched_at timestamp with time zone,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT lark_outbound_card_message_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'streaming'::text, 'final'::text, 'error'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lark_user_binding (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
workspace_id uuid NOT NULL,
|
||||
multica_user_id uuid NOT NULL,
|
||||
installation_id uuid NOT NULL,
|
||||
lark_open_id text NOT NULL,
|
||||
union_id text,
|
||||
bound_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY lark_binding_token
|
||||
ADD CONSTRAINT lark_binding_token_pkey PRIMARY KEY (token_hash);
|
||||
|
||||
ALTER TABLE ONLY lark_chat_session_binding
|
||||
ADD CONSTRAINT lark_chat_session_binding_chat_session_id_key UNIQUE (chat_session_id);
|
||||
|
||||
ALTER TABLE ONLY lark_chat_session_binding
|
||||
ADD CONSTRAINT lark_chat_session_binding_installation_id_lark_chat_id_key UNIQUE (installation_id, lark_chat_id);
|
||||
|
||||
ALTER TABLE ONLY lark_chat_session_binding
|
||||
ADD CONSTRAINT lark_chat_session_binding_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY lark_inbound_audit
|
||||
ADD CONSTRAINT lark_inbound_audit_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY lark_inbound_message_dedup
|
||||
ADD CONSTRAINT lark_inbound_message_dedup_pkey PRIMARY KEY (installation_id, message_id);
|
||||
|
||||
ALTER TABLE ONLY lark_installation
|
||||
ADD CONSTRAINT lark_installation_app_id_key UNIQUE (app_id);
|
||||
|
||||
ALTER TABLE ONLY lark_installation
|
||||
ADD CONSTRAINT lark_installation_id_workspace_id_key UNIQUE (id, workspace_id);
|
||||
|
||||
ALTER TABLE ONLY lark_installation
|
||||
ADD CONSTRAINT lark_installation_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY lark_installation
|
||||
ADD CONSTRAINT lark_installation_workspace_id_agent_id_key UNIQUE (workspace_id, agent_id);
|
||||
|
||||
ALTER TABLE ONLY lark_outbound_card_message
|
||||
ADD CONSTRAINT lark_outbound_card_message_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY lark_user_binding
|
||||
ADD CONSTRAINT lark_user_binding_installation_id_lark_open_id_key UNIQUE (installation_id, lark_open_id);
|
||||
|
||||
ALTER TABLE ONLY lark_user_binding
|
||||
ADD CONSTRAINT lark_user_binding_pkey PRIMARY KEY (id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lark_binding_token_installation ON lark_binding_token USING btree (installation_id, expires_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lark_chat_session_binding_session ON lark_chat_session_binding USING btree (chat_session_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lark_inbound_audit_installation ON lark_inbound_audit USING btree (installation_id, received_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lark_inbound_audit_reason ON lark_inbound_audit USING btree (drop_reason, received_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lark_inbound_dedup_received ON lark_inbound_message_dedup USING btree (received_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lark_installation_agent ON lark_installation USING btree (agent_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lark_installation_lease ON lark_installation USING btree (ws_lease_expires_at) WHERE (status = 'active'::text);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lark_installation_workspace ON lark_installation USING btree (workspace_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lark_outbound_card_session ON lark_outbound_card_message USING btree (chat_session_id, created_at DESC);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_lark_outbound_card_task ON lark_outbound_card_message USING btree (task_id) WHERE (task_id IS NOT NULL);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lark_user_binding_user ON lark_user_binding USING btree (multica_user_id, workspace_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lark_user_binding_workspace_open ON lark_user_binding USING btree (workspace_id, lark_open_id);
|
||||
|
||||
ALTER TABLE ONLY lark_binding_token
|
||||
ADD CONSTRAINT lark_binding_token_installation_id_fkey FOREIGN KEY (installation_id) REFERENCES lark_installation(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY lark_binding_token
|
||||
ADD CONSTRAINT lark_binding_token_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY lark_chat_session_binding
|
||||
ADD CONSTRAINT lark_chat_session_binding_chat_session_id_fkey FOREIGN KEY (chat_session_id) REFERENCES chat_session(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY lark_chat_session_binding
|
||||
ADD CONSTRAINT lark_chat_session_binding_installation_id_fkey FOREIGN KEY (installation_id) REFERENCES lark_installation(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY lark_inbound_audit
|
||||
ADD CONSTRAINT lark_inbound_audit_installation_id_fkey FOREIGN KEY (installation_id) REFERENCES lark_installation(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ONLY lark_inbound_message_dedup
|
||||
ADD CONSTRAINT lark_inbound_message_dedup_installation_id_fkey FOREIGN KEY (installation_id) REFERENCES lark_installation(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY lark_installation
|
||||
ADD CONSTRAINT lark_installation_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES agent(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY lark_installation
|
||||
ADD CONSTRAINT lark_installation_installer_user_id_fkey FOREIGN KEY (installer_user_id) REFERENCES "user"(id) ON DELETE RESTRICT;
|
||||
|
||||
ALTER TABLE ONLY lark_installation
|
||||
ADD CONSTRAINT lark_installation_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY lark_outbound_card_message
|
||||
ADD CONSTRAINT lark_outbound_card_message_chat_session_id_fkey FOREIGN KEY (chat_session_id) REFERENCES chat_session(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY lark_outbound_card_message
|
||||
ADD CONSTRAINT lark_outbound_card_message_task_id_fkey FOREIGN KEY (task_id) REFERENCES agent_task_queue(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ONLY lark_user_binding
|
||||
ADD CONSTRAINT lark_user_binding_installation_fk FOREIGN KEY (installation_id, workspace_id) REFERENCES lark_installation(id, workspace_id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY lark_user_binding
|
||||
ADD CONSTRAINT lark_user_binding_member_fk FOREIGN KEY (workspace_id, multica_user_id) REFERENCES member(workspace_id, user_id) ON DELETE CASCADE;
|
||||
|
||||
15
server/migrations/125_drop_lark_tables.up.sql
Normal file
15
server/migrations/125_drop_lark_tables.up.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Drop the lark_* tables. MUL-3515 generalized them into channel_* in
|
||||
-- migration 124 and the Go cutover has landed (the lark package now reads and
|
||||
-- writes only channel_*), so the old tables are dead. Per the design (§5:
|
||||
-- replace, do not keep both) the old path is removed rather than left in place.
|
||||
--
|
||||
-- Dropped in dependency order — every other lark_* table has a foreign key to
|
||||
-- lark_installation, so it goes last. The down migration recreates the schema
|
||||
-- (structure only; the data already lives in channel_*).
|
||||
DROP TABLE IF EXISTS lark_binding_token;
|
||||
DROP TABLE IF EXISTS lark_outbound_card_message;
|
||||
DROP TABLE IF EXISTS lark_inbound_audit;
|
||||
DROP TABLE IF EXISTS lark_inbound_message_dedup;
|
||||
DROP TABLE IF EXISTS lark_user_binding;
|
||||
DROP TABLE IF EXISTS lark_chat_session_binding;
|
||||
DROP TABLE IF EXISTS lark_installation;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -523,86 +523,6 @@ type IssueToLabel struct {
|
||||
LabelID pgtype.UUID `json:"label_id"`
|
||||
}
|
||||
|
||||
type LarkBindingToken struct {
|
||||
TokenHash string `json:"token_hash"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
InstallationID pgtype.UUID `json:"installation_id"`
|
||||
LarkOpenID string `json:"lark_open_id"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
ConsumedAt pgtype.Timestamptz `json:"consumed_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type LarkChatSessionBinding struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ChatSessionID pgtype.UUID `json:"chat_session_id"`
|
||||
InstallationID pgtype.UUID `json:"installation_id"`
|
||||
LarkChatID string `json:"lark_chat_id"`
|
||||
LarkChatType string `json:"lark_chat_type"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
LastLarkMessageID pgtype.Text `json:"last_lark_message_id"`
|
||||
LastLarkThreadID pgtype.Text `json:"last_lark_thread_id"`
|
||||
}
|
||||
|
||||
type LarkInboundAudit struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
InstallationID pgtype.UUID `json:"installation_id"`
|
||||
LarkChatID pgtype.Text `json:"lark_chat_id"`
|
||||
EventType string `json:"event_type"`
|
||||
LarkEventID pgtype.Text `json:"lark_event_id"`
|
||||
LarkMessageID pgtype.Text `json:"lark_message_id"`
|
||||
DropReason string `json:"drop_reason"`
|
||||
ReceivedAt pgtype.Timestamptz `json:"received_at"`
|
||||
}
|
||||
|
||||
type LarkInboundMessageDedup struct {
|
||||
InstallationID pgtype.UUID `json:"installation_id"`
|
||||
MessageID string `json:"message_id"`
|
||||
ReceivedAt pgtype.Timestamptz `json:"received_at"`
|
||||
ProcessedAt pgtype.Timestamptz `json:"processed_at"`
|
||||
ClaimToken pgtype.UUID `json:"claim_token"`
|
||||
}
|
||||
|
||||
type LarkInstallation struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
AgentID pgtype.UUID `json:"agent_id"`
|
||||
AppID string `json:"app_id"`
|
||||
AppSecretEncrypted []byte `json:"app_secret_encrypted"`
|
||||
TenantKey pgtype.Text `json:"tenant_key"`
|
||||
BotOpenID string `json:"bot_open_id"`
|
||||
InstallerUserID pgtype.UUID `json:"installer_user_id"`
|
||||
Status string `json:"status"`
|
||||
WsLeaseToken pgtype.Text `json:"ws_lease_token"`
|
||||
WsLeaseExpiresAt pgtype.Timestamptz `json:"ws_lease_expires_at"`
|
||||
InstalledAt pgtype.Timestamptz `json:"installed_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
BotUnionID pgtype.Text `json:"bot_union_id"`
|
||||
Region string `json:"region"`
|
||||
}
|
||||
|
||||
type LarkOutboundCardMessage struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
ChatSessionID pgtype.UUID `json:"chat_session_id"`
|
||||
TaskID pgtype.UUID `json:"task_id"`
|
||||
LarkChatID string `json:"lark_chat_id"`
|
||||
LarkCardMessageID string `json:"lark_card_message_id"`
|
||||
Status string `json:"status"`
|
||||
LastPatchedAt pgtype.Timestamptz `json:"last_patched_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type LarkUserBinding struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
MulticaUserID pgtype.UUID `json:"multica_user_id"`
|
||||
InstallationID pgtype.UUID `json:"installation_id"`
|
||||
LarkOpenID string `json:"lark_open_id"`
|
||||
UnionID pgtype.Text `json:"union_id"`
|
||||
BoundAt pgtype.Timestamptz `json:"bound_at"`
|
||||
}
|
||||
|
||||
type Member struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
|
||||
@@ -1,420 +0,0 @@
|
||||
-- Lark (飞书) Bot integration queries. The migration that defines these
|
||||
-- tables lives at server/migrations/109_lark_integration.up.sql; the
|
||||
-- architectural boundaries the package enforces on top of them are
|
||||
-- documented in server/internal/integrations/lark/doc.go.
|
||||
--
|
||||
-- Scoping convention: every public-facing read goes through a
|
||||
-- workspace-scoped variant where one exists. The lookups that take only
|
||||
-- a UUID PK (e.g. GetLarkInstallation) are reserved for internal trusted
|
||||
-- callers (the WS lease scanner, the inbound dispatcher after identity
|
||||
-- resolution); HTTP handlers should prefer the *InWorkspace forms.
|
||||
|
||||
-- =====================
|
||||
-- lark_installation
|
||||
-- =====================
|
||||
|
||||
-- name: CreateLarkInstallation :one
|
||||
-- Used by the OAuth callback. `app_secret_encrypted` is the ciphertext
|
||||
-- produced by internal/util/secretbox — never plaintext. The
|
||||
-- (workspace_id, agent_id) UNIQUE constraint enforces the spec rule
|
||||
-- "one Multica Agent ↔ one Lark Bot"; re-installing on the same agent
|
||||
-- goes through UpsertLarkInstallation instead.
|
||||
INSERT INTO lark_installation (
|
||||
workspace_id, agent_id, app_id, app_secret_encrypted,
|
||||
tenant_key, bot_open_id, bot_union_id, installer_user_id
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, sqlc.narg('tenant_key'), $5, sqlc.narg('bot_union_id'), $6
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpsertLarkInstallation :one
|
||||
-- Re-install path: a user who already bound this agent to Lark scans
|
||||
-- the QR again (e.g. they rotated their Lark app secret, or revoked +
|
||||
-- reinstalled). We refresh the app credentials, bot identity, and
|
||||
-- installer attribution, and force status back to 'active'. The WS
|
||||
-- lease is intentionally NOT reset here — the inbound hub owns lease
|
||||
-- lifecycle.
|
||||
INSERT INTO lark_installation (
|
||||
workspace_id, agent_id, app_id, app_secret_encrypted,
|
||||
tenant_key, bot_open_id, bot_union_id, installer_user_id, region
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, sqlc.narg('tenant_key'), $5, sqlc.narg('bot_union_id'), $6, sqlc.arg('region')
|
||||
)
|
||||
ON CONFLICT (workspace_id, agent_id) DO UPDATE SET
|
||||
app_id = EXCLUDED.app_id,
|
||||
app_secret_encrypted = EXCLUDED.app_secret_encrypted,
|
||||
tenant_key = EXCLUDED.tenant_key,
|
||||
bot_open_id = EXCLUDED.bot_open_id,
|
||||
bot_union_id = EXCLUDED.bot_union_id,
|
||||
installer_user_id = EXCLUDED.installer_user_id,
|
||||
region = EXCLUDED.region,
|
||||
status = 'active',
|
||||
installed_at = now(),
|
||||
updated_at = now()
|
||||
RETURNING *;
|
||||
|
||||
-- name: BackfillLarkInstallationRegionToLark :execrows
|
||||
-- Upgrade repair: flip every installation still carrying the migration-116
|
||||
-- default ('feishu') to 'lark'. Called ONLY by
|
||||
-- BackfillRegionFromLegacyOverride, and ONLY when the deployment's global
|
||||
-- base-URL override pointed at Lark international — on such a deployment the
|
||||
-- whole integration talked to open.larksuite.com, so every existing install
|
||||
-- is really Lark and the migration's mainland default mislabels it.
|
||||
-- Idempotent: once flipped there is nothing left at 'feishu' to update, and
|
||||
-- new installs already carry the device-flow-detected region.
|
||||
UPDATE lark_installation
|
||||
SET region = 'lark',
|
||||
updated_at = now()
|
||||
WHERE region = 'feishu';
|
||||
|
||||
-- name: SetLarkInstallationBotUnionID :exec
|
||||
-- Operator-only backfill for installations created before the
|
||||
-- bot_union_id column existed (migration 112). Production reads do
|
||||
-- NOT use this — finishSuccess writes union_id during install, and
|
||||
-- the upsert path writes it on re-install. Kept as a focused single-
|
||||
-- column UPDATE so the backfill cannot accidentally overwrite app
|
||||
-- credentials, status, or lease state.
|
||||
UPDATE lark_installation
|
||||
SET bot_union_id = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetLarkInstallation :one
|
||||
SELECT * FROM lark_installation WHERE id = $1;
|
||||
|
||||
-- name: GetLarkInstallationInWorkspace :one
|
||||
SELECT * FROM lark_installation
|
||||
WHERE id = $1 AND workspace_id = $2;
|
||||
|
||||
-- name: GetLarkInstallationByAgent :one
|
||||
SELECT * FROM lark_installation
|
||||
WHERE workspace_id = $1 AND agent_id = $2;
|
||||
|
||||
-- name: GetLarkInstallationByAppID :one
|
||||
-- Used by the OAuth callback to detect re-install vs first-install,
|
||||
-- and by the inbound dispatcher to route an event payload (which only
|
||||
-- carries app_id) to its installation row.
|
||||
SELECT * FROM lark_installation WHERE app_id = $1;
|
||||
|
||||
-- name: ListLarkInstallationsByWorkspace :many
|
||||
SELECT * FROM lark_installation
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: ListActiveLarkInstallations :many
|
||||
-- Boot path for the WebSocket hub: enumerate every active installation
|
||||
-- so the hub can claim leases and open long connections. Excludes
|
||||
-- revoked rows — their WS should already be torn down.
|
||||
SELECT * FROM lark_installation
|
||||
WHERE status = 'active'
|
||||
ORDER BY created_at ASC;
|
||||
|
||||
-- name: SetLarkInstallationStatus :exec
|
||||
UPDATE lark_installation
|
||||
SET status = $2, updated_at = now()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: AcquireLarkWSLease :one
|
||||
-- Atomically claims the WebSocket lease for an installation. The CAS
|
||||
-- predicate accepts the lease when (a) no current holder exists, (b)
|
||||
-- the holder's lease has expired, or (c) the holder is us (renewal).
|
||||
-- Returns the row when the lease was successfully claimed; returns no
|
||||
-- rows when another live holder still owns it.
|
||||
UPDATE lark_installation
|
||||
SET ws_lease_token = sqlc.arg('new_token'),
|
||||
ws_lease_expires_at = sqlc.arg('new_expires_at'),
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg('id')
|
||||
AND status = 'active'
|
||||
AND (
|
||||
ws_lease_token IS NULL
|
||||
OR ws_lease_expires_at < now()
|
||||
OR ws_lease_token = sqlc.arg('new_token')
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ReleaseLarkWSLease :exec
|
||||
-- Drops the lease iff we're still the holder. A racing acquirer that
|
||||
-- already took over will not have its lease cleared.
|
||||
UPDATE lark_installation
|
||||
SET ws_lease_token = NULL,
|
||||
ws_lease_expires_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
AND ws_lease_token = sqlc.arg('current_token');
|
||||
|
||||
-- =====================
|
||||
-- lark_user_binding
|
||||
-- =====================
|
||||
|
||||
-- name: CreateLarkUserBinding :one
|
||||
-- Records that a Lark open_id (per-installation) maps to a Multica
|
||||
-- user.
|
||||
--
|
||||
-- Two structural guarantees:
|
||||
-- 1. The composite FK to member(workspace_id, user_id) makes this
|
||||
-- statement fail when the redeemer is not (or no longer) a
|
||||
-- workspace member — that is §4.3 of the design.
|
||||
-- 2. ON CONFLICT DO UPDATE is gated on `multica_user_id` matching
|
||||
-- the existing binding, so a second redeemer holding their own
|
||||
-- valid binding token CANNOT silently steal an already-bound
|
||||
-- open_id. If the conflict row points at a different user, the
|
||||
-- UPDATE is skipped and the statement returns ZERO rows — the
|
||||
-- caller (lark.BindingTokenService.RedeemAndBind) translates
|
||||
-- that into ErrBindingAlreadyAssigned.
|
||||
--
|
||||
-- The same-user case still updates metadata (union_id refresh,
|
||||
-- bound_at bump) so an idempotent re-bind by the original user
|
||||
-- continues to work; only a cross-user re-assignment is rejected.
|
||||
-- True account changes must go through an explicit unbind flow, not
|
||||
-- through a binding token.
|
||||
INSERT INTO lark_user_binding (
|
||||
workspace_id, multica_user_id, installation_id, lark_open_id, union_id
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, sqlc.narg('union_id')
|
||||
)
|
||||
ON CONFLICT (installation_id, lark_open_id) DO UPDATE SET
|
||||
union_id = COALESCE(EXCLUDED.union_id, lark_user_binding.union_id),
|
||||
bound_at = now()
|
||||
WHERE lark_user_binding.multica_user_id = EXCLUDED.multica_user_id
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetLarkUserBindingByOpenID :one
|
||||
-- The inbound identity check. A row here means: this open_id maps to a
|
||||
-- Multica user who IS currently a workspace member (the composite FK
|
||||
-- cascades the binding away when membership is revoked, so a row's
|
||||
-- existence is itself the membership proof).
|
||||
SELECT * FROM lark_user_binding
|
||||
WHERE installation_id = $1 AND lark_open_id = $2;
|
||||
|
||||
-- name: ListLarkUserBindingsByInstallation :many
|
||||
SELECT * FROM lark_user_binding
|
||||
WHERE installation_id = $1
|
||||
ORDER BY bound_at DESC;
|
||||
|
||||
-- name: DeleteLarkUserBinding :exec
|
||||
DELETE FROM lark_user_binding WHERE id = $1;
|
||||
|
||||
-- =====================
|
||||
-- lark_chat_session_binding
|
||||
-- =====================
|
||||
|
||||
-- name: CreateLarkChatSessionBinding :one
|
||||
INSERT INTO lark_chat_session_binding (
|
||||
chat_session_id, installation_id, lark_chat_id, lark_chat_type
|
||||
) VALUES (
|
||||
$1, $2, $3, $4
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetLarkChatSessionBinding :one
|
||||
-- Lookup-by-Lark-chat path. Used by the inbound dispatcher to find the
|
||||
-- existing chat_session before deciding whether to create one. The
|
||||
-- UNIQUE (installation_id, lark_chat_id) constraint means at most one
|
||||
-- row matches.
|
||||
SELECT * FROM lark_chat_session_binding
|
||||
WHERE installation_id = $1 AND lark_chat_id = $2;
|
||||
|
||||
-- name: GetLarkChatSessionBindingBySession :one
|
||||
-- Reverse lookup: given a chat_session_id, find its Lark binding. Used
|
||||
-- by the outbound card patcher to know which (installation, chat_id)
|
||||
-- to PATCH when an agent emits a stream event for this session.
|
||||
SELECT * FROM lark_chat_session_binding
|
||||
WHERE chat_session_id = $1;
|
||||
|
||||
-- name: UpdateLarkChatSessionBindingReplyTarget :exec
|
||||
-- Records the most recent inbound trigger message + the Lark topic
|
||||
-- (thread) it belongs to, so the decoupled outbound patcher can thread
|
||||
-- its reply back into the originating 话题. Called on every ingested
|
||||
-- message (in the same tx as the chat_message write). last_lark_thread_id
|
||||
-- is NULL for non-thread messages, which keeps the outbound on the
|
||||
-- existing chat-level send path; a non-NULL value flips the next reply
|
||||
-- into a thread reply targeting last_lark_message_id.
|
||||
UPDATE lark_chat_session_binding
|
||||
SET last_lark_message_id = sqlc.narg('last_lark_message_id'),
|
||||
last_lark_thread_id = sqlc.narg('last_lark_thread_id')
|
||||
WHERE chat_session_id = $1;
|
||||
|
||||
-- =====================
|
||||
-- lark_inbound_message_dedup
|
||||
-- =====================
|
||||
|
||||
-- name: ClaimLarkInboundDedup :one
|
||||
-- The two-phase idempotency gate. The dispatcher uses this BEFORE
|
||||
-- group filter / identity check / chat-session lookup so a WebSocket
|
||||
-- reconnect that replays an event cannot re-trigger binding prompts,
|
||||
-- re-write drop audit rows, or re-touch chat_session.
|
||||
--
|
||||
-- Returns the row when a claim is acquired:
|
||||
-- - newly inserted (first delivery of this message_id), OR
|
||||
-- - re-taken from a stale in-flight claim. A claim is stale when
|
||||
-- processed_at IS NULL AND received_at is older than 60 seconds —
|
||||
-- the previous worker crashed or lost its DB connection between
|
||||
-- claim and finalize, and a retry should be allowed to proceed.
|
||||
--
|
||||
-- Returns NO rows (pgx.ErrNoRows) when the claim cannot be acquired:
|
||||
-- - the row exists with processed_at IS NOT NULL (terminal: prior
|
||||
-- attempt reached a durable outcome), OR
|
||||
-- - the row exists with processed_at IS NULL AND received_at within
|
||||
-- the last 60 seconds (another worker is actively processing).
|
||||
--
|
||||
-- Owner fencing: every successful Claim mints a fresh UUID into
|
||||
-- `claim_token`. The Caller passes that token to MarkLarkInbound-
|
||||
-- DedupProcessed / ReleaseLarkInboundDedup; mismatched tokens are
|
||||
-- ignored. A stale-reclaim that re-takes the row ROTATES the token,
|
||||
-- so the previous (slow but still alive) worker can no longer Mark
|
||||
-- the row — its same-tx Mark returns zero rows and the chat_message
|
||||
-- write rolls back. See lark_inbound_message_dedup table comment.
|
||||
--
|
||||
-- The dispatcher MUST follow up every successful claim with exactly one
|
||||
-- of MarkLarkInboundDedupProcessed (durable outcome) or
|
||||
-- ReleaseLarkInboundDedup (infra failure before durable outcome),
|
||||
-- supplying the returned claim_token. Otherwise the row sits as an
|
||||
-- in-flight claim and the next replay attempt must wait for the
|
||||
-- staleness TTL.
|
||||
INSERT INTO lark_inbound_message_dedup (installation_id, message_id, claim_token)
|
||||
VALUES ($1, $2, gen_random_uuid())
|
||||
ON CONFLICT (installation_id, message_id) DO UPDATE
|
||||
SET received_at = now(),
|
||||
claim_token = gen_random_uuid()
|
||||
WHERE lark_inbound_message_dedup.processed_at IS NULL
|
||||
AND lark_inbound_message_dedup.received_at < now() - INTERVAL '60 seconds'
|
||||
RETURNING installation_id, message_id, received_at, processed_at, claim_token;
|
||||
|
||||
-- name: MarkLarkInboundDedupProcessed :execrows
|
||||
-- Locks in a claim as permanently processed. Called by the dispatcher
|
||||
-- after a durable outcome has been reached:
|
||||
-- - a drop audit row was persisted (group filter / unbound user /
|
||||
-- revoked / invalid event), OR
|
||||
-- - chat_message + chat_session.updated_at were committed (ingest
|
||||
-- path, including ingest paths that subsequently fail at issue
|
||||
-- creation / task enqueue — the user-visible message is already in
|
||||
-- the session).
|
||||
-- For the chat_message ingest path the dispatcher invokes this query
|
||||
-- INSIDE the chat_message+session transaction (via qtx), so the
|
||||
-- durable write and the Mark commit atomically. A token mismatch
|
||||
-- (another worker has re-claimed the row in the meantime) returns
|
||||
-- zero rows; the caller treats that as a lost claim and rolls back the
|
||||
-- in-tx invocation, so no second chat_message is written.
|
||||
--
|
||||
-- Guarded by processed_at IS NULL so a successful Mark is itself
|
||||
-- idempotent: replaying it cannot resurrect a row that was already
|
||||
-- terminal.
|
||||
UPDATE lark_inbound_message_dedup
|
||||
SET processed_at = now()
|
||||
WHERE installation_id = $1
|
||||
AND message_id = $2
|
||||
AND claim_token = $3
|
||||
AND processed_at IS NULL;
|
||||
|
||||
-- name: ReleaseLarkInboundDedup :execrows
|
||||
-- Releases an in-flight claim. Called by the dispatcher when an infra
|
||||
-- error occurred BEFORE any durable side effect (e.g. EnsureChatSession
|
||||
-- or AppendUserMessage returned an error and its transaction rolled
|
||||
-- back). Deleting the row lets the WS adapter's retry re-acquire the
|
||||
-- claim immediately, instead of waiting for the 60-second staleness
|
||||
-- TTL. Guarded by processed_at IS NULL so an out-of-order Release
|
||||
-- cannot undo a Mark; guarded by claim_token so a slow-but-alive worker
|
||||
-- whose claim was reclaimed cannot delete the new holder's row.
|
||||
DELETE FROM lark_inbound_message_dedup
|
||||
WHERE installation_id = $1
|
||||
AND message_id = $2
|
||||
AND claim_token = $3
|
||||
AND processed_at IS NULL;
|
||||
|
||||
-- name: PurgeLarkInboundDedup :exec
|
||||
-- Removes dedup rows older than the supplied cutoff. The vacuum job
|
||||
-- (separate cron) calls this with cutoff = now() - INTERVAL '24h'.
|
||||
-- Sweeps both processed and (very old) abandoned in-flight rows.
|
||||
DELETE FROM lark_inbound_message_dedup
|
||||
WHERE received_at < $1;
|
||||
|
||||
-- =====================
|
||||
-- lark_inbound_audit
|
||||
-- =====================
|
||||
|
||||
-- name: RecordLarkInboundDrop :exec
|
||||
-- The ONLY write path for events that fail identity check or the
|
||||
-- group-mention filter. Deliberately accepts no body column — the
|
||||
-- AuditLogger interface in internal/integrations/lark mirrors that
|
||||
-- shape so a caller cannot accidentally hand a body to this row.
|
||||
INSERT INTO lark_inbound_audit (
|
||||
installation_id, lark_chat_id, event_type,
|
||||
lark_event_id, lark_message_id, drop_reason
|
||||
) VALUES (
|
||||
sqlc.narg('installation_id'),
|
||||
sqlc.narg('lark_chat_id'),
|
||||
$1,
|
||||
sqlc.narg('lark_event_id'),
|
||||
sqlc.narg('lark_message_id'),
|
||||
$2
|
||||
);
|
||||
|
||||
-- name: ListLarkInboundAuditByInstallation :many
|
||||
-- Ops debugging view; paged via the (installation_id, received_at) idx.
|
||||
SELECT * FROM lark_inbound_audit
|
||||
WHERE installation_id = $1
|
||||
ORDER BY received_at DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
|
||||
-- =====================
|
||||
-- lark_outbound_card_message
|
||||
-- =====================
|
||||
|
||||
-- name: CreateLarkOutboundCardMessage :one
|
||||
INSERT INTO lark_outbound_card_message (
|
||||
chat_session_id, task_id, lark_chat_id, lark_card_message_id, status
|
||||
) VALUES (
|
||||
$1, sqlc.narg('task_id'), $2, $3, $4
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetLarkOutboundCardByTask :one
|
||||
-- Most card patches arrive keyed by task_id (we're streaming an agent
|
||||
-- run's output). The partial unique index on (task_id) WHERE task_id IS
|
||||
-- NOT NULL guarantees this returns at most one row.
|
||||
SELECT * FROM lark_outbound_card_message
|
||||
WHERE task_id = $1;
|
||||
|
||||
-- name: UpdateLarkOutboundCardStatus :exec
|
||||
UPDATE lark_outbound_card_message
|
||||
SET status = $2,
|
||||
last_patched_at = now()
|
||||
WHERE id = $1;
|
||||
|
||||
-- =====================
|
||||
-- lark_binding_token
|
||||
-- =====================
|
||||
|
||||
-- name: CreateLarkBindingToken :one
|
||||
-- Mints a single-use binding token for an unbound Lark user. The TTL
|
||||
-- cap (`expires_at <= created_at + INTERVAL '15 minutes'`) is enforced
|
||||
-- by the DB CHECK on the table, in lockstep with lark.BindingTokenTTL.
|
||||
-- We store the HASH, not the raw token; the raw value is returned to
|
||||
-- the caller exactly once (in the URL it embeds in the Bot's reply
|
||||
-- card) and never persisted server-side.
|
||||
INSERT INTO lark_binding_token (
|
||||
token_hash, workspace_id, installation_id, lark_open_id, expires_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ConsumeLarkBindingToken :one
|
||||
-- Atomic redemption. Returns the row only if (a) the hash exists, (b)
|
||||
-- it has not been consumed, and (c) it has not expired. The UPDATE +
|
||||
-- RETURNING pattern guarantees that two simultaneous redemptions of
|
||||
-- the same token cannot both succeed — exactly one row update wins,
|
||||
-- the other sees zero rows.
|
||||
UPDATE lark_binding_token
|
||||
SET consumed_at = now()
|
||||
WHERE token_hash = $1
|
||||
AND consumed_at IS NULL
|
||||
AND expires_at > now()
|
||||
RETURNING *;
|
||||
|
||||
-- name: PurgeExpiredLarkBindingTokens :exec
|
||||
-- Tokens are tiny but unbounded over time. The same vacuum cron that
|
||||
-- handles dedup can sweep these too.
|
||||
DELETE FROM lark_binding_token
|
||||
WHERE expires_at < $1;
|
||||
Reference in New Issue
Block a user