feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s

落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 02:26:16 +08:00
co-authored by Cursor
parent 7e9023f0a8
commit 7ab9add5dd
132 changed files with 8276 additions and 491 deletions
+129 -5
View File
@@ -18,16 +18,98 @@ type ReportRepo struct {
Pool *pgxpool.Pool
}
// Create inserts a growth report.
// Create inserts a growth report (optional peer for pair reports).
func (r *ReportRepo) Create(ctx context.Context, userID, profileID uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) {
return r.CreateWithPeer(ctx, userID, profileID, nil, typ, summary, detail)
}
// CreateWithPeer inserts a report with optional peer_profile_id.
func (r *ReportRepo) CreateWithPeer(ctx context.Context, userID, profileID uuid.UUID, peer *uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) {
rep := &model.GrowthReport{}
err := r.Pool.QueryRow(ctx, `
INSERT INTO growth_reports(user_id, profile_id, type, summary, detail)
VALUES ($1,$2,$3,$4,$5)
INSERT INTO growth_reports(user_id, profile_id, peer_profile_id, type, summary, detail)
VALUES ($1,$2,$3,$4,$5,$6)
RETURNING id, user_id, profile_id, type, summary, detail, created_at`,
userID, profileID, typ, summary, detail,
userID, profileID, peer, typ, summary, detail,
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
return rep, err
if err != nil {
return nil, err
}
rep.PeerProfileID = peer
return rep, nil
}
// SoftDeleteMatching soft-deletes prior reports for overwrite semantics.
func (r *ReportRepo) SoftDeleteMatching(ctx context.Context, userID, profileID uuid.UUID, peer *uuid.UUID, typ string) error {
if peer == nil {
_, err := r.Pool.Exec(ctx, `
UPDATE growth_reports SET deleted_at=now(), updated_at=now()
WHERE user_id=$1 AND profile_id=$2 AND type=$3
AND peer_profile_id IS NULL AND deleted_at IS NULL`,
userID, profileID, typ)
return err
}
_, err := r.Pool.Exec(ctx, `
UPDATE growth_reports SET deleted_at=now(), updated_at=now()
WHERE user_id=$1 AND type=$2 AND deleted_at IS NULL
AND (
(profile_id=$3 AND peer_profile_id=$4) OR
(profile_id=$4 AND peer_profile_id=$3)
)`,
userID, typ, profileID, *peer)
return err
}
// UpsertWithPeer soft-deletes matching then inserts.
func (r *ReportRepo) UpsertWithPeer(ctx context.Context, userID, profileID uuid.UUID, peer *uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) {
if err := r.SoftDeleteMatching(ctx, userID, profileID, peer, typ); err != nil {
return nil, err
}
return r.CreateWithPeer(ctx, userID, profileID, peer, typ, summary, detail)
}
// GetLatest returns newest non-deleted report for profile+type(+peer).
func (r *ReportRepo) GetLatest(ctx context.Context, userID, profileID uuid.UUID, typ string, peer *uuid.UUID) (*model.GrowthReport, error) {
rep := &model.GrowthReport{}
var peerOut *uuid.UUID
var err error
if peer == nil {
err = r.Pool.QueryRow(ctx, `
SELECT id, user_id, profile_id, peer_profile_id, type, summary, detail, created_at
FROM growth_reports
WHERE user_id=$1 AND profile_id=$2 AND type=$3
AND peer_profile_id IS NULL AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
userID, profileID, typ,
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &peerOut, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
} else {
err = r.Pool.QueryRow(ctx, `
SELECT id, user_id, profile_id, peer_profile_id, type, summary, detail, created_at
FROM growth_reports
WHERE user_id=$1 AND type=$2 AND deleted_at IS NULL
AND (
(profile_id=$3 AND peer_profile_id=$4) OR
(profile_id=$4 AND peer_profile_id=$3)
)
ORDER BY created_at DESC LIMIT 1`,
userID, typ, profileID, *peer,
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &peerOut, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
}
if err != nil {
return nil, err
}
rep.PeerProfileID = peerOut
return rep, nil
}
// SoftDeleteForProfile marks all reports involving a profile as deleted.
func (r *ReportRepo) SoftDeleteForProfile(ctx context.Context, userID, profileID uuid.UUID) error {
_, err := r.Pool.Exec(ctx, `
UPDATE growth_reports SET deleted_at=now(), updated_at=now()
WHERE user_id=$1 AND deleted_at IS NULL
AND (profile_id=$2 OR peer_profile_id=$2)`,
userID, profileID)
return err
}
// GetForUser loads a report owned by user.
@@ -188,10 +270,52 @@ func (r *ReportRepo) PayMock(ctx context.Context, userID, orderID uuid.UUID) err
userID, p, days); err != nil {
return err
}
case "ask_pack":
p := "pack10"
if plan != nil && *plan != "" {
p = *plan
}
delta := AskPackQuota(p)
if delta <= 0 {
return errString("invalid ask_pack plan")
}
if _, err := tx.Exec(ctx, `
UPDATE users SET ask_paid_quota_left = ask_paid_quota_left + $2, updated_at=now()
WHERE id=$1 AND deleted_at IS NULL`, userID, delta); err != nil {
return err
}
}
return tx.Commit(ctx)
}
// AskPackQuota returns how many ask replies a pack plan grants.
func AskPackQuota(plan string) int {
switch plan {
case "pack10":
return 10
case "pack30":
return 30
case "pack100":
return 100
default:
return 0
}
}
// AskPackAmountCents is mock price for an ask pack plan.
func AskPackAmountCents(plan string) int {
switch plan {
case "pack10":
return 990
case "pack30":
return 1980
case "pack100":
return 4990
default:
return 0
}
}
var errMissingReport = errString("report_id required for deep_access")
type errString string