feat: add safety reporting APIs

This commit is contained in:
richjohnrambo
2026-08-14 00:01:54 +08:00
parent fa75c0f0f9
commit 170bbdca76
10 changed files with 1344 additions and 6 deletions
+393
View File
@@ -3890,6 +3890,399 @@ func (a *adapter) TenantDevicePushTokenUpsert(id t.TenantID, token *t.DevicePush
return token, nil
}
func (a *adapter) TenantRiskChallengeCreate(id t.TenantID, challenge *t.RiskChallenge) (*t.RiskChallenge, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantRiskChallengeCreate(id, challenge)
}
}
if err := validTenant(id); err != nil || challenge == nil || challenge.UserID.IsZero() ||
strings.TrimSpace(challenge.ChallengeID) == "" || strings.TrimSpace(challenge.Action) == "" || challenge.ExpiresAt.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
params, err := json.Marshal(challenge.NoticeParams)
if err != nil {
return nil, err
}
now := t.TimeNow()
if challenge.State == "" {
challenge.State = t.RiskChallengeStatePending
}
challenge.Level = strings.ToLower(strings.TrimSpace(challenge.Level))
challenge.NoticeCode = strings.TrimSpace(challenge.NoticeCode)
_, err = a.db.ExecContext(ctx,
`INSERT INTO im_risk_challenges(challenge_id,user_id,action,level,notice_code,notice_params,state,expires_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE updated_at=updated_at`,
strings.TrimSpace(challenge.ChallengeID), decodeUID(challenge.UserID), strings.TrimSpace(challenge.Action),
challenge.Level, challenge.NoticeCode, string(params), challenge.State, challenge.ExpiresAt, now, now)
if err != nil {
return nil, err
}
return a.riskChallengeByID(ctx, challenge.ChallengeID)
}
func (a *adapter) TenantRiskChallengeConfirm(id t.TenantID, uid t.Uid, challengeID, decision, actionTokenID string, now time.Time) (*t.RiskChallenge, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantRiskChallengeConfirm(id, uid, challengeID, decision, actionTokenID, now)
}
}
decision = strings.ToLower(strings.TrimSpace(decision))
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(challengeID) == "" ||
(decision != "continue" && decision != "cancel") {
return nil, t.ErrMalformed
}
if now.IsZero() {
now = t.TimeNow()
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer func() {
if tx != nil {
_ = tx.Rollback()
}
}()
challenge, err := a.riskChallengeByIDTx(ctx, tx, challengeID, true)
if err != nil {
return nil, err
}
if challenge.UserID != uid || challenge.State != t.RiskChallengeStatePending || !now.Before(challenge.ExpiresAt) {
return nil, t.ErrInvalidResponse
}
nextState := t.RiskChallengeStateCanceled
if decision == "continue" {
nextState = t.RiskChallengeStateContinued
}
_, err = tx.ExecContext(ctx,
`UPDATE im_risk_challenges SET state=?,decision=?,action_token_id=?,updated_at=?
WHERE challenge_id=? AND state=?`,
nextState, decision, strings.TrimSpace(actionTokenID), now, challengeID, t.RiskChallengeStatePending)
if err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
tx = nil
return a.riskChallengeByID(ctx, challengeID)
}
func (a *adapter) TenantAbuseReasons(id t.TenantID, targetType string) ([]t.AbuseReason, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantAbuseReasons(id, targetType)
}
}
targetType = strings.ToLower(strings.TrimSpace(targetType))
if err := validTenant(id); err != nil || targetType == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var reasons []t.AbuseReason
err := sqlx.SelectContext(ctx, a.db, &reasons,
`SELECT target_type,reason_code,label,requires_description,allows_evidence,sort_order
FROM im_abuse_reasons WHERE target_type=? AND enabled=1 ORDER BY sort_order,id`, targetType)
return reasons, err
}
func (a *adapter) TenantAbuseReportCreate(id t.TenantID, report *t.AbuseReport, idempotencyKey string) (*t.AbuseReport, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantAbuseReportCreate(id, report, idempotencyKey)
}
}
if err := validTenant(id); err != nil || report == nil || report.ReporterID.IsZero() ||
strings.TrimSpace(report.ReportID) == "" || strings.TrimSpace(report.ReasonCode) == "" || strings.TrimSpace(idempotencyKey) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
evidence, err := json.Marshal(report.EvidenceFileIDs)
if err != nil {
return nil, err
}
snapshot, err := json.Marshal(report.Snapshot)
if err != nil {
return nil, err
}
now := t.TimeNow()
report.Status = t.AbuseReportStatusReceived
_, err = a.db.ExecContext(ctx,
`INSERT INTO im_abuse_reports(report_id,reporter_id,target_type,target_user_id,target_topic,target_seq,reason_code,description,evidence_file_ids,snapshot,idempotency_key,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE updated_at=updated_at`,
report.ReportID, decodeUID(report.ReporterID), report.TargetType, decodeUID(report.TargetUserID), report.TargetTopic,
report.TargetSeq, report.ReasonCode, report.Description, string(evidence), string(snapshot), strings.TrimSpace(idempotencyKey),
report.Status, now, now)
if err != nil {
return nil, err
}
return a.abuseReportByIdempotency(ctx, report.ReporterID, idempotencyKey)
}
func (a *adapter) TenantFeedbackConfigGet(id t.TenantID, uid t.Uid) (*t.FeedbackConfig, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFeedbackConfigGet(id, uid)
}
}
if err := validTenant(id); err != nil || uid.IsZero() {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
return a.feedbackConfig(ctx)
}
func (a *adapter) TenantFeedbackCreate(id t.TenantID, feedback *t.FeedbackSubmission, idempotencyKey string) (*t.FeedbackSubmission, error) {
if !a.tenantScopedDB {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantFeedbackCreate(id, feedback, idempotencyKey)
}
}
if err := validTenant(id); err != nil || feedback == nil || feedback.UserID.IsZero() ||
strings.TrimSpace(feedback.FeedbackID) == "" || strings.TrimSpace(feedback.Category) == "" ||
strings.TrimSpace(feedback.Content) == "" || strings.TrimSpace(idempotencyKey) == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
attachments, err := json.Marshal(feedback.AttachmentFileIDs)
if err != nil {
return nil, err
}
clientContext, err := json.Marshal(feedback.ClientContext)
if err != nil {
return nil, err
}
now := t.TimeNow()
feedback.Status = t.FeedbackStatusReceived
_, err = a.db.ExecContext(ctx,
`INSERT INTO im_feedback_submissions(feedback_id,user_id,category,content,contact,attachment_file_ids,client_context,include_diagnostics,idempotency_key,status,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE updated_at=updated_at`,
feedback.FeedbackID, decodeUID(feedback.UserID), feedback.Category, feedback.Content, feedback.Contact,
string(attachments), string(clientContext), feedback.IncludeDiagnostics, strings.TrimSpace(idempotencyKey),
feedback.Status, now, now)
if err != nil {
return nil, err
}
return a.feedbackByIdempotency(ctx, feedback.UserID, idempotencyKey)
}
func (a *adapter) riskChallengeByID(ctx context.Context, challengeID string) (*t.RiskChallenge, error) {
return a.riskChallengeByIDTx(ctx, a.db, challengeID, false)
}
func (a *adapter) riskChallengeByIDTx(ctx context.Context, q sqlx.QueryerContext, challengeID string, forUpdate bool) (*t.RiskChallenge, error) {
row := struct {
ID int64 `db:"id"`
ChallengeID string `db:"challenge_id"`
UserID int64 `db:"user_id"`
Action string `db:"action"`
Level string `db:"level"`
NoticeCode string `db:"notice_code"`
NoticeParams []byte `db:"notice_params"`
State string `db:"state"`
ExpiresAt time.Time `db:"expires_at"`
Decision string `db:"decision"`
ActionTokenID string `db:"action_token_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
query := `SELECT id,challenge_id,user_id,action,level,notice_code,notice_params,state,expires_at,decision,action_token_id,created_at,updated_at
FROM im_risk_challenges WHERE challenge_id=?`
if forUpdate {
query += " FOR UPDATE"
}
if err := sqlx.GetContext(ctx, q, &row, query, challengeID); err != nil {
return nil, err
}
challenge := &t.RiskChallenge{
ID: row.ID,
ChallengeID: row.ChallengeID,
UserID: store.EncodeUid(row.UserID),
Action: row.Action,
Level: row.Level,
NoticeCode: row.NoticeCode,
NoticeParams: map[string]any{},
State: row.State,
ExpiresAt: row.ExpiresAt,
Decision: row.Decision,
ActionTokenID: row.ActionTokenID,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.NoticeParams, &challenge.NoticeParams)
return challenge, nil
}
func (a *adapter) abuseReportByIdempotency(ctx context.Context, uid t.Uid, idempotencyKey string) (*t.AbuseReport, error) {
row := struct {
ID int64 `db:"id"`
ReportID string `db:"report_id"`
ReporterID int64 `db:"reporter_id"`
TargetType string `db:"target_type"`
TargetUserID int64 `db:"target_user_id"`
TargetTopic string `db:"target_topic"`
TargetSeq int `db:"target_seq"`
ReasonCode string `db:"reason_code"`
Description string `db:"description"`
EvidenceFileIDs []byte `db:"evidence_file_ids"`
Snapshot []byte `db:"snapshot"`
Status string `db:"status"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row,
`SELECT id,report_id,reporter_id,target_type,target_user_id,target_topic,target_seq,reason_code,description,evidence_file_ids,snapshot,status,created_at,updated_at
FROM im_abuse_reports WHERE reporter_id=? AND idempotency_key=?`,
decodeUID(uid), strings.TrimSpace(idempotencyKey)); err != nil {
return nil, err
}
report := &t.AbuseReport{
ID: row.ID,
ReportID: row.ReportID,
ReporterID: store.EncodeUid(row.ReporterID),
TargetType: row.TargetType,
TargetUserID: store.EncodeUid(row.TargetUserID),
TargetTopic: row.TargetTopic,
TargetSeq: row.TargetSeq,
ReasonCode: row.ReasonCode,
Description: row.Description,
EvidenceFileIDs: []string{},
Snapshot: map[string]any{},
Status: row.Status,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.EvidenceFileIDs, &report.EvidenceFileIDs)
_ = json.Unmarshal(row.Snapshot, &report.Snapshot)
return report, nil
}
func (a *adapter) feedbackConfig(ctx context.Context) (*t.FeedbackConfig, error) {
row := struct {
Categories []byte `db:"categories"`
MaxLength int `db:"max_length"`
AttachmentPolicy []byte `db:"attachment_policy"`
AllowContact bool `db:"allow_contact"`
DiagnosticsNotice string `db:"diagnostics_notice"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row,
`SELECT categories,max_length,attachment_policy,allow_contact,diagnostics_notice,updated_at
FROM im_feedback_config ORDER BY id LIMIT 1`); err != nil {
if err == sql.ErrNoRows {
now := t.TimeNow()
return &t.FeedbackConfig{
Categories: []string{"bug", "suggestion", "complaint", "other"},
MaxLength: 1000,
AttachmentPolicy: map[string]any{"max_files": float64(5), "purpose": "feedback"},
AllowContact: true,
DiagnosticsNotice: "仅在你同意时上传脱敏诊断信息。",
UpdatedAt: now,
}, nil
}
return nil, err
}
config := &t.FeedbackConfig{
Categories: []string{"bug", "suggestion", "complaint", "other"},
MaxLength: row.MaxLength,
AttachmentPolicy: map[string]any{},
AllowContact: row.AllowContact,
DiagnosticsNotice: row.DiagnosticsNotice,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.Categories, &config.Categories)
_ = json.Unmarshal(row.AttachmentPolicy, &config.AttachmentPolicy)
return config, nil
}
func (a *adapter) feedbackByIdempotency(ctx context.Context, uid t.Uid, idempotencyKey string) (*t.FeedbackSubmission, error) {
row := struct {
ID int64 `db:"id"`
FeedbackID string `db:"feedback_id"`
UserID int64 `db:"user_id"`
Category string `db:"category"`
Content string `db:"content"`
Contact string `db:"contact"`
AttachmentFileIDs []byte `db:"attachment_file_ids"`
ClientContext []byte `db:"client_context"`
IncludeDiagnostics bool `db:"include_diagnostics"`
Status string `db:"status"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}{}
if err := sqlx.GetContext(ctx, a.db, &row,
`SELECT id,feedback_id,user_id,category,content,contact,attachment_file_ids,client_context,include_diagnostics,status,created_at,updated_at
FROM im_feedback_submissions WHERE user_id=? AND idempotency_key=?`,
decodeUID(uid), strings.TrimSpace(idempotencyKey)); err != nil {
return nil, err
}
feedback := &t.FeedbackSubmission{
ID: row.ID,
FeedbackID: row.FeedbackID,
UserID: store.EncodeUid(row.UserID),
Category: row.Category,
Content: row.Content,
Contact: row.Contact,
AttachmentFileIDs: []string{},
ClientContext: map[string]any{},
IncludeDiagnostics: row.IncludeDiagnostics,
Status: row.Status,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
_ = json.Unmarshal(row.AttachmentFileIDs, &feedback.AttachmentFileIDs)
_ = json.Unmarshal(row.ClientContext, &feedback.ClientContext)
return feedback, nil
}
func (a *adapter) userProfileByUID(ctx context.Context, uid t.Uid) (*t.UserProfile, error) {
row := struct {
UserID int64 `db:"user_id"`