Poll pending requests every 15 minutes, emit deduplicated escalation notifications, and close requests when creator confirms stage advance. Co-authored-by: Cursor <cursoragent@cursor.com>
173 lines
5.2 KiB
Go
173 lines
5.2 KiB
Go
package notification
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// stageReminderAfter 首次提醒后多久发送 24h 升级提醒(测试可覆盖)。
|
|
var stageReminderAfter = 24 * time.Hour
|
|
|
|
// SetStageReminderAfterForTest 仅供测试缩短等待窗口。
|
|
func SetStageReminderAfterForTest(d time.Duration) {
|
|
if d > 0 {
|
|
stageReminderAfter = d
|
|
}
|
|
}
|
|
|
|
type pendingAdvanceRow struct {
|
|
ID int64
|
|
StageInstanceID int64
|
|
WorkflowInstanceID int64
|
|
StageKey string
|
|
ReadinessRevision int
|
|
ProjectID int64
|
|
CreatorUserID int64
|
|
StageName string
|
|
RequiredDocCount int
|
|
ApprovedDocCount int
|
|
}
|
|
|
|
// ProcessStageAdvanceReminders 扫描 pending 推进待办,对超过 24h 未处理的创建升级提醒(去重)。
|
|
func (s *Service) ProcessStageAdvanceReminders(ctx context.Context) (int, error) {
|
|
if !s.Available() {
|
|
return 0, ErrNotAvailable
|
|
}
|
|
|
|
cutoff := time.Now().Add(-stageReminderAfter)
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT sar.id, sar.stage_instance_id, sar.workflow_instance_id, sar.stage_key, sar.readiness_revision,
|
|
pwi.project_id, COALESCE(p.creator_user_id, 0),
|
|
COALESCE(e.payload, '{}'::jsonb)
|
|
FROM stage_advance_requests sar
|
|
JOIN project_workflow_instances pwi ON pwi.id = sar.workflow_instance_id
|
|
JOIN projects p ON p.id = pwi.project_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT payload FROM outbox_events
|
|
WHERE aggregate_type = 'stage_instance'
|
|
AND aggregate_id = 'stage_instance:' || sar.stage_instance_id::text
|
|
AND event_type = $1
|
|
ORDER BY id DESC LIMIT 1
|
|
) e ON true
|
|
WHERE sar.status = 'pending' AND sar.requested_at <= $2
|
|
`, EventStageReadyToAdvance, cutoff)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
sent := 0
|
|
for rows.Next() {
|
|
var row pendingAdvanceRow
|
|
var payloadRaw []byte
|
|
if err := rows.Scan(
|
|
&row.ID, &row.StageInstanceID, &row.WorkflowInstanceID, &row.StageKey, &row.ReadinessRevision,
|
|
&row.ProjectID, &row.CreatorUserID, &payloadRaw,
|
|
); err != nil {
|
|
return sent, err
|
|
}
|
|
if row.CreatorUserID <= 0 {
|
|
continue
|
|
}
|
|
if len(payloadRaw) > 0 {
|
|
var payload StageReadyPayload
|
|
_ = json.Unmarshal(payloadRaw, &payload)
|
|
row.StageName = payload.StageName
|
|
row.RequiredDocCount = payload.RequiredDocCount
|
|
row.ApprovedDocCount = payload.ApprovedDocCount
|
|
}
|
|
created, err := s.emitStageReminder24(ctx, row)
|
|
if err != nil {
|
|
return sent, err
|
|
}
|
|
if created {
|
|
sent++
|
|
}
|
|
}
|
|
return sent, rows.Err()
|
|
}
|
|
|
|
func (s *Service) emitStageReminder24(ctx context.Context, row pendingAdvanceRow) (bool, error) {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
payload := StageReadyPayload{
|
|
ProjectID: row.ProjectID,
|
|
WorkflowInstanceID: row.WorkflowInstanceID,
|
|
StageInstanceID: row.StageInstanceID,
|
|
StageKey: row.StageKey,
|
|
StageName: row.StageName,
|
|
ConfigRevision: row.ReadinessRevision,
|
|
RequiredDocCount: row.RequiredDocCount,
|
|
ApprovedDocCount: row.ApprovedDocCount,
|
|
}
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
dedupeKey := fmt.Sprintf("stage_ready_remind24:%d:%d", row.StageInstanceID, row.ReadinessRevision)
|
|
var existingID int64
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT id FROM notifications WHERE user_id = $1 AND dedupe_key = $2
|
|
`, row.CreatorUserID, dedupeKey).Scan(&existingID)
|
|
if err == nil && existingID > 0 {
|
|
return false, nil
|
|
}
|
|
if err != nil && err != pgx.ErrNoRows {
|
|
return false, err
|
|
}
|
|
|
|
var eventID int64
|
|
aggregateID := fmt.Sprintf("stage_instance:%d", row.StageInstanceID)
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload, project_id, published_at)
|
|
VALUES ('stage_instance', $1, $2, $3::jsonb, $4, now())
|
|
RETURNING id
|
|
`, aggregateID, EventStageReadyReminder24, string(raw), row.ProjectID).Scan(&eventID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
stageLabel := row.StageName
|
|
if stageLabel == "" {
|
|
stageLabel = row.StageKey
|
|
}
|
|
title := fmt.Sprintf("【提醒】阶段「%s」仍可推进", stageLabel)
|
|
body := "必需文档已全部批准,已超过 24 小时尚未确认推进。请尽快查看完成清单并确认是否进入下一阶段。"
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO notifications (user_id, project_id, event_id, type, title, body, dedupe_key, status)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 'unread')
|
|
ON CONFLICT (user_id, dedupe_key) DO NOTHING
|
|
`, row.CreatorUserID, row.ProjectID, eventID, TypeStageReadyReminder24, title, body, dedupeKey)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// ConfirmStageAdvanceRequest 阶段已确认推进后关闭 pending 待办,停止后续提醒。
|
|
func (s *Service) ConfirmStageAdvanceRequest(ctx context.Context, stageInstanceID int64, readinessRevision int, decidedBy int64) error {
|
|
if !s.Available() || stageInstanceID <= 0 {
|
|
return nil
|
|
}
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE stage_advance_requests
|
|
SET status = 'confirmed', decided_by = $3, decided_at = now()
|
|
WHERE stage_instance_id = $1 AND readiness_revision = $2 AND status = 'pending'
|
|
`, stageInstanceID, readinessRevision, decidedBy)
|
|
return err
|
|
}
|