diff --git a/docs/IM-第一二章后续增强项.md b/docs/IM-第一二章后续增强项.md index 265a212..b58999b 100644 --- a/docs/IM-第一二章后续增强项.md +++ b/docs/IM-第一二章后续增强项.md @@ -1,7 +1,7 @@ -# IM 第一至九章后续增强项 +# IM 第一至十一章后续增强项 > 日期:2026-08-13 -> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章群消息与 @、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构 +> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章群消息与 @、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构、第十章个人设置与客户端能力、第十一章反诈安全/举报/反馈 > 用途:记录当前已完成基线之外的增强项,后续统一排期处理。 ## 1. 第一章:登录、注册与安全 @@ -267,7 +267,30 @@ | P1 | 资料隐私裁剪落地 | 隐私设置需要接入用户搜索、组织架构、会话资料页等读路径。 | 非授权用户无法通过其他接口绕过隐私配置。 | | P2 | 客户端能力上报 | 后续增加设备能力、通知权限、厂商通道状态和前台活跃状态上报。 | 后台可定位推送不可达和版本兼容问题。 | -## 11. 统一处理建议 +## 11. 反诈安全、举报与意见反馈 + +### 11.1 当前已完成基线 + +1. URL 安全检查 `POST /v1/im/security/urls/check` 已接入风控 challenge 创建。 +2. 风控确认 `POST /v1/im/security/risk-challenges/{id}/confirm` 已具备,`continue` 返回短时 `risk_action_token`。 +3. 举报原因 `GET /v1/im/reports/abuse/reasons` 已具备,默认覆盖用户、群、消息目标。 +4. 举报提交 `POST /v1/im/reports/abuse` 已具备,支持幂等键、证据文件 ID、目标基础可见性校验和审核快照。 +5. 意见反馈配置 `GET /v1/im/feedback/config`、提交 `POST /v1/im/feedback` 已具备,支持附件规则、诊断授权和幂等键。 + +### 11.2 后续增强项 + +| 优先级 | 增强项 | 说明 | 建议验收点 | +| --- | --- | --- | --- | +| P0 | 风控策略引擎 | 当前 URL 检查仅基础规则,后续需要按动作、关系、内容、设备、频率和租户策略综合判定。 | 高风险发消息、加好友、打开链接等动作统一返回风控 challenge。 | +| P0 | 风控 token 消费接入业务接口 | 当前确认可签发 `risk_action_token`,后续需要原业务接口校验并单次消费该 token。 | 二次确认后只允许重试原动作一次,重复使用返回 Token 已消费。 | +| P0 | 举报目标完整权限校验 | 当前做基础可见性校验,后续需要结合用户侧删除、撤回、退群、黑名单、文件 ACL 和审核状态。 | 无权限目标无法提交举报,也不泄露目标存在性。 | +| P0 | 审核后台流转 | 举报和反馈当前仅 received,后续需要后台分派、处理、关闭、回复和通知。 | 处理状态可追踪,举报人按策略收到处理结果。 | +| P1 | 证据文件安全与保留期 | 证据文件需要 `abuse_evidence` 专用 ACL、病毒扫描、不可转发和保留期策略。 | 证据只能审核链路访问,过期按政策清理。 | +| P1 | 反诈系统事件可信生产 | 低风险提示需通过可信 `security.risk_notice` 系统事件发送,并限制普通消息伪装。 | 普通用户无法伪造反诈弹窗,客户端只信任系统事件。 | +| P1 | 举报合并与限频 | 重复举报需要合并、返回原受理号或限频错误,避免刷单。 | 短时间重复举报同一目标不会生成大量工单。 | +| P2 | 反馈诊断包脱敏 | 诊断包需统一脱敏、大小限制、客户端日志授权和服务端下载审计。 | 未同意时没有诊断包,同意后包内不含明文 token/手机号。 | + +## 12. 统一处理建议 1. 先做 P0:统一幂等、实时事件/Outbox、验证码真实发送与限频、P2P/好友/搜索隐私策略、第六章群业务独立表与事务状态机、文件下载签名、通话强状态机、工作台/组织权限。 2. 再做 P1:租户策略配置、系统通知生产通道、实名审核、设备管理、资料隐私裁剪、关系审计、群禁言发送校验、群文件索引、通话质量指标、会议/日报/月报/信箱完整业务表。 diff --git a/scripts/apply_hotgo_tenant_db.go b/scripts/apply_hotgo_tenant_db.go index b059494..816d89d 100644 --- a/scripts/apply_hotgo_tenant_db.go +++ b/scripts/apply_hotgo_tenant_db.go @@ -311,6 +311,91 @@ func main() { updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(user_id, purpose) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS tinode.im_risk_challenges( + id BIGINT NOT NULL AUTO_INCREMENT, + challenge_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + action VARCHAR(64) NOT NULL, + level VARCHAR(16) NOT NULL, + notice_code VARCHAR(64) NOT NULL, + notice_params JSON, + state VARCHAR(16) NOT NULL DEFAULT 'pending', + expires_at DATETIME(3) NOT NULL, + decision VARCHAR(16) NOT NULL DEFAULT '', + action_token_id VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_risk_challenges_challenge(challenge_id), + INDEX idx_im_risk_challenges_user_state(user_id, state, expires_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS tinode.im_abuse_reasons( + id BIGINT NOT NULL AUTO_INCREMENT, + target_type VARCHAR(16) NOT NULL, + reason_code VARCHAR(64) NOT NULL, + label VARCHAR(128) NOT NULL, + requires_description TINYINT NOT NULL DEFAULT 0, + allows_evidence TINYINT NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + enabled TINYINT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_abuse_reasons_type_code(target_type, reason_code), + INDEX idx_im_abuse_reasons_type_order(target_type, enabled, sort_order) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS tinode.im_abuse_reports( + id BIGINT NOT NULL AUTO_INCREMENT, + report_id VARCHAR(64) NOT NULL, + reporter_id BIGINT NOT NULL, + target_type VARCHAR(16) NOT NULL, + target_user_id BIGINT NOT NULL DEFAULT 0, + target_topic VARCHAR(32) NOT NULL DEFAULT '', + target_seq INT NOT NULL DEFAULT 0, + reason_code VARCHAR(64) NOT NULL, + description TEXT NOT NULL, + evidence_file_ids JSON, + snapshot JSON, + idempotency_key VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'received', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_abuse_reports_report(report_id), + UNIQUE INDEX uk_im_abuse_reports_idempotency(reporter_id, idempotency_key), + INDEX idx_im_abuse_reports_target(target_type, target_topic, target_seq, target_user_id), + INDEX idx_im_abuse_reports_reporter(reporter_id, id DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS tinode.im_feedback_config( + id BIGINT NOT NULL AUTO_INCREMENT, + categories JSON, + max_length INT NOT NULL DEFAULT 1000, + attachment_policy JSON, + allow_contact TINYINT NOT NULL DEFAULT 1, + diagnostics_notice TEXT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, + `CREATE TABLE IF NOT EXISTS tinode.im_feedback_submissions( + id BIGINT NOT NULL AUTO_INCREMENT, + feedback_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + category VARCHAR(64) NOT NULL, + content TEXT NOT NULL, + contact VARCHAR(128) NOT NULL DEFAULT '', + attachment_file_ids JSON, + client_context JSON, + include_diagnostics TINYINT NOT NULL DEFAULT 0, + idempotency_key VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'received', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_feedback_submissions_feedback(feedback_id), + UNIQUE INDEX uk_im_feedback_submissions_idempotency(user_id, idempotency_key), + INDEX idx_im_feedback_submissions_user(user_id, id DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, } for _, stmt := range statements { if _, err = db.Exec(stmt); err != nil { @@ -368,6 +453,26 @@ func main() { if _, err = db.Exec("UPDATE tinode.im_tenant SET db_name='tinode' WHERE code='default' AND (db_name='' OR db_name IS NULL)"); err != nil { fatal(err) } + if _, err = db.Exec(`INSERT INTO tinode.im_abuse_reasons(target_type,reason_code,label,requires_description,allows_evidence,sort_order) + VALUES + ('user','fraud','诈骗欺诈',0,1,10), + ('user','harassment','骚扰辱骂',0,1,20), + ('user','illegal','违法违规',1,1,30), + ('group','fraud','诈骗欺诈',0,1,10), + ('group','spam','垃圾广告',0,1,20), + ('group','illegal','违法违规',1,1,30), + ('message','fraud','诈骗欺诈',0,1,10), + ('message','spam','垃圾广告',0,1,20), + ('message','harassment','骚扰辱骂',0,1,30), + ('message','illegal','违法违规',1,1,40) + ON DUPLICATE KEY UPDATE label=VALUES(label),requires_description=VALUES(requires_description),allows_evidence=VALUES(allows_evidence),sort_order=VALUES(sort_order),updated_at=CURRENT_TIMESTAMP(3)`); err != nil { + fatal(err) + } + if _, err = db.Exec(`INSERT INTO tinode.im_feedback_config(id,categories,max_length,attachment_policy,allow_contact,diagnostics_notice) + VALUES(1,'["bug","suggestion","complaint","other"]',1000,'{"max_files":5,"purpose":"feedback"}',1,'仅在你同意时上传脱敏诊断信息。') + ON DUPLICATE KEY UPDATE updated_at=updated_at`); err != nil { + fatal(err) + } if err = dropTenantIDColumns(db, "tinode"); err != nil { fatal(err) } diff --git a/server/db/adapter.go b/server/db/adapter.go index 4f4cf4c..3013775 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -325,6 +325,12 @@ type TenantBusinessAdapter interface { TenantUserPrivacyUpdate(t.TenantID, t.Uid, t.UserPrivacyPatch) (*t.UserPrivacySettings, error) TenantAppReleaseLatest(t.TenantID, string, string) (*t.AppRelease, error) TenantDevicePushTokenUpsert(t.TenantID, *t.DevicePushToken) (*t.DevicePushToken, error) + TenantRiskChallengeCreate(t.TenantID, *t.RiskChallenge) (*t.RiskChallenge, error) + TenantRiskChallengeConfirm(t.TenantID, t.Uid, string, string, string, time.Time) (*t.RiskChallenge, error) + TenantAbuseReasons(t.TenantID, string) ([]t.AbuseReason, error) + TenantAbuseReportCreate(t.TenantID, *t.AbuseReport, string) (*t.AbuseReport, error) + TenantFeedbackConfigGet(t.TenantID, t.Uid) (*t.FeedbackConfig, error) + TenantFeedbackCreate(t.TenantID, *t.FeedbackSubmission, string) (*t.FeedbackSubmission, error) TenantFavoriteCreate(t.TenantID, *t.FavoriteItem) (*t.FavoriteItem, error) TenantFavoriteList(t.TenantID, t.Uid, string, string, string, int) ([]t.FavoriteItem, string, error) diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index ff6609a..46c2fda 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -1252,6 +1252,91 @@ func createIMUserProductTables(tx *sql.Tx) error { UNIQUE INDEX uk_im_device_push_tokens_provider_token(provider, token), INDEX idx_im_device_push_tokens_user(user_id, updated_at) )`, + `CREATE TABLE im_risk_challenges( + id BIGINT NOT NULL AUTO_INCREMENT, + challenge_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + action VARCHAR(64) NOT NULL, + level VARCHAR(16) NOT NULL, + notice_code VARCHAR(64) NOT NULL, + notice_params JSON, + state VARCHAR(16) NOT NULL DEFAULT 'pending', + expires_at DATETIME(3) NOT NULL, + decision VARCHAR(16) NOT NULL DEFAULT '', + action_token_id VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_risk_challenges_challenge(challenge_id), + INDEX idx_im_risk_challenges_user_state(user_id, state, expires_at) + )`, + `CREATE TABLE im_abuse_reasons( + id BIGINT NOT NULL AUTO_INCREMENT, + target_type VARCHAR(16) NOT NULL, + reason_code VARCHAR(64) NOT NULL, + label VARCHAR(128) NOT NULL, + requires_description TINYINT NOT NULL DEFAULT 0, + allows_evidence TINYINT NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + enabled TINYINT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_abuse_reasons_type_code(target_type, reason_code), + INDEX idx_im_abuse_reasons_type_order(target_type, enabled, sort_order) + )`, + `CREATE TABLE im_abuse_reports( + id BIGINT NOT NULL AUTO_INCREMENT, + report_id VARCHAR(64) NOT NULL, + reporter_id BIGINT NOT NULL, + target_type VARCHAR(16) NOT NULL, + target_user_id BIGINT NOT NULL DEFAULT 0, + target_topic VARCHAR(32) NOT NULL DEFAULT '', + target_seq INT NOT NULL DEFAULT 0, + reason_code VARCHAR(64) NOT NULL, + description TEXT NOT NULL, + evidence_file_ids JSON, + snapshot JSON, + idempotency_key VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'received', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_abuse_reports_report(report_id), + UNIQUE INDEX uk_im_abuse_reports_idempotency(reporter_id, idempotency_key), + INDEX idx_im_abuse_reports_target(target_type, target_topic, target_seq, target_user_id), + INDEX idx_im_abuse_reports_reporter(reporter_id, id DESC) + )`, + `CREATE TABLE im_feedback_config( + id BIGINT NOT NULL AUTO_INCREMENT, + categories JSON, + max_length INT NOT NULL DEFAULT 1000, + attachment_policy JSON, + allow_contact TINYINT NOT NULL DEFAULT 1, + diagnostics_notice TEXT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id) + )`, + `CREATE TABLE im_feedback_submissions( + id BIGINT NOT NULL AUTO_INCREMENT, + feedback_id VARCHAR(64) NOT NULL, + user_id BIGINT NOT NULL, + category VARCHAR(64) NOT NULL, + content TEXT NOT NULL, + contact VARCHAR(128) NOT NULL DEFAULT '', + attachment_file_ids JSON, + client_context JSON, + include_diagnostics TINYINT NOT NULL DEFAULT 0, + idempotency_key VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'received', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), + UNIQUE INDEX uk_im_feedback_submissions_feedback(feedback_id), + UNIQUE INDEX uk_im_feedback_submissions_idempotency(user_id, idempotency_key), + INDEX idx_im_feedback_submissions_user(user_id, id DESC) + )`, } for _, stmt := range statements { if _, err := tx.Exec(stmt); err != nil { diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 2553a5c..6d47558 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -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"` diff --git a/server/im_http.go b/server/im_http.go index dd72bb2..394b8e9 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -40,6 +40,7 @@ const ( imReasonRealNameRequired = "REALNAME_REQUIRED" imReasonActionToken = "ACTION_TOKEN_INVALID" imReasonPermissionDenied = "PERMISSION_DENIED" + imReasonRiskConfirmation = "RISK_CONFIRMATION_REQUIRED" imReasonInternal = "INTERNAL_ERROR" ) @@ -133,6 +134,26 @@ type imDevicePushTokenRequest struct { Platform string `json:"platform,omitempty"` } +type imRiskChallengeConfirmRequest struct { + Decision string `json:"decision"` +} + +type imAbuseReportRequest struct { + Target types.AbuseTarget `json:"target"` + ReasonCode string `json:"reason_code"` + Description string `json:"description,omitempty"` + EvidenceFileIDs []string `json:"evidence_file_ids,omitempty"` +} + +type imFeedbackRequest struct { + Category string `json:"category"` + Content string `json:"content"` + Contact string `json:"contact,omitempty"` + AttachmentFileIDs []string `json:"attachment_file_ids,omitempty"` + ClientContext map[string]any `json:"client_context,omitempty"` + IncludeDiagnostics bool `json:"include_diagnostics,omitempty"` +} + type imSystemNotificationReadRequest struct { ReadAt string `json:"read_at,omitempty"` } @@ -663,7 +684,8 @@ func serveIMSecurityURLCheck(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) return } - if _, ok := authenticateIMHTTPBearer(wrt, req); !ok { + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { return } var body imURLCheckRequest @@ -679,11 +701,208 @@ func serveIMSecurityURLCheck(wrt http.ResponseWriter, req *http.Request) { "reason": reason, } if decision != "allow" { - payload["risk_challenge_id"] = "url_" + shortTokenHash(strings.ToLower(strings.TrimSpace(body.URL))) + challengeID := "risk_" + shortTokenHash(fmt.Sprintf("%d:%s:%d", principal.Uid, strings.ToLower(strings.TrimSpace(body.URL)), time.Now().UnixNano())) + expiresAt := time.Now().UTC().Round(time.Millisecond).Add(10 * time.Minute) + if _, err := store.Safety.CreateRiskChallenge(principal.TenantID, &types.RiskChallenge{ + ChallengeID: challengeID, + UserID: principal.Uid, + Action: "open_url", + Level: riskLevel, + NoticeCode: "url_" + riskLevel, + NoticeParams: map[string]any{"source": strings.TrimSpace(body.Source)}, + State: types.RiskChallengeStatePending, + ExpiresAt: expiresAt, + }); err != nil { + writeIMRelationError(wrt, req, err) + return + } + payload["risk_challenge_id"] = challengeID + payload["expires_at"] = expiresAt.Format(time.RFC3339Nano) } writeIMSuccess(wrt, req, payload) } +func serveIMRiskChallengeConfirm(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + challengeID := riskChallengeIDFromPath(req.URL.Path) + if challengeID == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "risk_challenge_id"}) + return + } + var body imRiskChallengeConfirmRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + decision := strings.ToLower(strings.TrimSpace(body.Decision)) + var tokenID string + var token string + var expiresAt time.Time + var err error + if decision == "continue" { + token, expiresAt, err = issueIMActionToken(principal.TenantID, principal.Uid, "risk_continue", challengeID) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + tokenID, _, _ = strings.Cut(token, ".") + } + challenge, err := store.Safety.ConfirmRiskChallenge(principal.TenantID, principal.Uid, challengeID, decision, tokenID, time.Now().UTC().Round(time.Millisecond)) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + payload := riskChallengePayload(challenge) + if decision == "continue" { + payload["risk_action_token"] = token + payload["token_action"] = "risk_continue" + payload["token_expires_at"] = expiresAt.Format(time.RFC3339Nano) + } + writeIMSuccess(wrt, req, payload) +} + +func serveIMAbuseReasons(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + targetType := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("target_type"))) + reasons, err := store.Safety.AbuseReasons(principal.TenantID, targetType) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + items := make([]map[string]any, 0, len(reasons)) + for _, reason := range reasons { + items = append(items, abuseReasonPayload(reason)) + } + writeIMSuccess(wrt, req, map[string]any{"items": items}) +} + +func serveIMAbuseReports(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + var body imAbuseReportRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key")) + if idempotencyKey == "" { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"}) + return + } + targetType, targetUser, targetTopic, targetSeq, snapshot, err := normalizeIMAbuseTarget(principal.TenantID, principal.Uid, body.Target) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + description := strings.TrimSpace(body.Description) + if len([]rune(description)) > 500 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "description"}) + return + } + if len(body.EvidenceFileIDs) > 9 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "evidence_file_ids"}) + return + } + token, err := randomIMToken(12) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + report, err := store.Safety.CreateAbuseReport(principal.TenantID, &types.AbuseReport{ + ReportID: "ar_" + token, + ReporterID: principal.Uid, + TargetType: targetType, + TargetUserID: targetUser, + TargetTopic: targetTopic, + TargetSeq: targetSeq, + ReasonCode: strings.TrimSpace(body.ReasonCode), + Description: description, + EvidenceFileIDs: cleanStringList(body.EvidenceFileIDs, 9), + Snapshot: snapshot, + }, idempotencyKey) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, abuseReportPayload(report)) +} + +func serveIMFeedbackConfig(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + config, err := store.Safety.FeedbackConfig(principal.TenantID, principal.Uid) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, feedbackConfigPayload(config)) +} + +func serveIMFeedback(wrt http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) + return + } + principal, ok := authenticateIMHTTPBearer(wrt, req) + if !ok { + return + } + var body imFeedbackRequest + if !decodeIMJSONBody(wrt, req, &body) { + return + } + idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key")) + content := strings.TrimSpace(body.Content) + if idempotencyKey == "" || strings.TrimSpace(body.Category) == "" || content == "" || len([]rune(content)) > 1000 || len(body.AttachmentFileIDs) > 5 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) + return + } + token, err := randomIMToken(12) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + feedback, err := store.Safety.CreateFeedback(principal.TenantID, &types.FeedbackSubmission{ + FeedbackID: "fb_" + token, + UserID: principal.Uid, + Category: strings.TrimSpace(body.Category), + Content: content, + Contact: strings.TrimSpace(body.Contact), + AttachmentFileIDs: cleanStringList(body.AttachmentFileIDs, 5), + ClientContext: body.ClientContext, + IncludeDiagnostics: body.IncludeDiagnostics, + }, idempotencyKey) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } + writeIMSuccess(wrt, req, feedbackSubmissionPayload(feedback)) +} + func serveIMSecurityChallenges(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -5426,6 +5645,57 @@ func devicePushTokenPayload(token *types.DevicePushToken) map[string]any { } } +func riskChallengePayload(challenge *types.RiskChallenge) map[string]any { + return map[string]any{ + "risk_challenge_id": challenge.ChallengeID, + "action": challenge.Action, + "level": challenge.Level, + "notice_code": challenge.NoticeCode, + "notice_params": challenge.NoticeParams, + "state": challenge.State, + "decision": challenge.Decision, + "expires_at": challenge.ExpiresAt.UTC().Format(time.RFC3339Nano), + "updated_at": challenge.UpdatedAt.UTC().Format(time.RFC3339Nano), + } +} + +func abuseReasonPayload(reason types.AbuseReason) map[string]any { + return map[string]any{ + "target_type": reason.TargetType, + "reason_code": reason.ReasonCode, + "label": reason.Label, + "requires_description": reason.RequiresDescription, + "allows_evidence": reason.AllowsEvidence, + } +} + +func abuseReportPayload(report *types.AbuseReport) map[string]any { + return map[string]any{ + "report_id": report.ReportID, + "status": report.Status, + "created_at": report.CreatedAt.UTC().Format(time.RFC3339Nano), + } +} + +func feedbackConfigPayload(config *types.FeedbackConfig) map[string]any { + return map[string]any{ + "categories": config.Categories, + "max_length": config.MaxLength, + "attachment_policy": config.AttachmentPolicy, + "allow_contact": config.AllowContact, + "diagnostics_notice": config.DiagnosticsNotice, + "updated_at": config.UpdatedAt.UTC().Format(time.RFC3339Nano), + } +} + +func feedbackSubmissionPayload(feedback *types.FeedbackSubmission) map[string]any { + return map[string]any{ + "feedback_id": feedback.FeedbackID, + "status": feedback.Status, + "created_at": feedback.CreatedAt.UTC().Format(time.RFC3339Nano), + } +} + func isAllowedIMWorkbenchRoute(route string) bool { switch route { case "/organization", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox": @@ -6228,6 +6498,110 @@ func securityChallengeIDFromPath(path string) string { return strings.TrimSpace(challengeID) } +func riskChallengeIDFromPath(path string) string { + const marker = "/v1/im/security/risk-challenges/" + idx := strings.Index(path, marker) + if idx < 0 { + return "" + } + rest := strings.TrimPrefix(path[idx:], marker) + challengeID, suffix, ok := strings.Cut(rest, "/") + if !ok || suffix != "confirm" { + return "" + } + return strings.TrimSpace(challengeID) +} + +func cleanStringList(values []string, limit int) []string { + if limit <= 0 { + limit = len(values) + } + capacity := len(values) + if capacity > limit { + capacity = limit + } + cleaned := make([]string, 0, capacity) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + cleaned = append(cleaned, value) + if len(cleaned) >= limit { + break + } + } + return cleaned +} + +func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target types.AbuseTarget) (string, types.Uid, string, int, map[string]any, error) { + targetType := strings.ToLower(strings.TrimSpace(target.Type)) + snapshot := map[string]any{"type": targetType} + switch targetType { + case "user": + uid := types.ParseUserId(strings.TrimSpace(target.UserID)) + if uid.IsZero() || uid == reporter { + return "", 0, "", 0, nil, types.ErrMalformed + } + if store.Users != nil { + if _, err := store.Users.Get(tenantID, uid); err != nil { + return "", 0, "", 0, nil, err + } + } + snapshot["user_id"] = uid.UserId() + return targetType, uid, "", 0, snapshot, nil + case "group": + topic := strings.TrimSpace(target.Topic) + if types.GetTopicCat(topic) != types.TopicCatGrp { + return "", 0, "", 0, nil, types.ErrMalformed + } + if err := requireIMTopicReadable(tenantID, reporter, topic); err != nil { + return "", 0, "", 0, nil, err + } + snapshot["topic"] = topic + return targetType, 0, topic, 0, snapshot, nil + case "message": + topic := strings.TrimSpace(target.Topic) + seq, err := strconv.Atoi(strings.TrimSpace(target.Seq)) + if topic == "" || seq <= 0 || err != nil { + return "", 0, "", 0, nil, types.ErrMalformed + } + if err := requireIMTopicReadable(tenantID, reporter, topic); err != nil { + return "", 0, "", 0, nil, err + } + msg, err := store.Messages.GetOne(tenantID, topic, seq) + if err != nil { + return "", 0, "", 0, nil, err + } + snapshot["topic"] = topic + snapshot["seq"] = seq + snapshot["from"] = msg.From + snapshot["ts"] = msg.CreatedAt.UTC().Format(time.RFC3339Nano) + return targetType, 0, topic, seq, snapshot, nil + default: + return "", 0, "", 0, nil, types.ErrMalformed + } +} + +func requireIMTopicReadable(tenantID types.TenantID, uid types.Uid, topic string) error { + if store.Subs == nil { + return types.ErrPermissionDenied + } + sub, err := store.Subs.Get(tenantID, topic, uid, false) + if err != nil { + return err + } + if sub == nil || !sub.ModeGiven.IsReader() { + return types.ErrPermissionDenied + } + return nil +} + func systemNotificationIDFromPath(path string) string { const marker = "/v1/im/system-notifications/" idx := strings.Index(path, marker) diff --git a/server/im_http_test.go b/server/im_http_test.go index 18b5b3f..d1bf0e1 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -112,6 +112,13 @@ type testDevicePushTokenStore struct { token *types.DevicePushToken } +type testSafetyStore struct { + riskChallenge *types.RiskChallenge + reasons []types.AbuseReason + abuseReport *types.AbuseReport + feedback *types.FeedbackSubmission +} + func (s *testWorkbenchStore) Get(_ types.TenantID, _ types.Uid) ([]types.WorkbenchModule, int, error) { if s.version == 0 { s.version = 1 @@ -370,6 +377,65 @@ func (s *testDevicePushTokenStore) Upsert(_ types.TenantID, token *types.DeviceP return token, nil } +func (s *testSafetyStore) CreateRiskChallenge(_ types.TenantID, challenge *types.RiskChallenge) (*types.RiskChallenge, error) { + now := time.Now().UTC().Round(time.Millisecond) + challenge.CreatedAt = now + challenge.UpdatedAt = now + s.riskChallenge = challenge + return challenge, nil +} + +func (s *testSafetyStore) ConfirmRiskChallenge(_ types.TenantID, uid types.Uid, challengeID, decision, actionTokenID string, now time.Time) (*types.RiskChallenge, error) { + if s.riskChallenge == nil { + s.riskChallenge = &types.RiskChallenge{ChallengeID: challengeID, UserID: uid, Action: "open_url", Level: "high", NoticeCode: "url_high", ExpiresAt: now.Add(time.Minute)} + } + s.riskChallenge.State = types.RiskChallengeStateCanceled + if decision == "continue" { + s.riskChallenge.State = types.RiskChallengeStateContinued + } + s.riskChallenge.Decision = decision + s.riskChallenge.ActionTokenID = actionTokenID + s.riskChallenge.UpdatedAt = now + return s.riskChallenge, nil +} + +func (s *testSafetyStore) AbuseReasons(_ types.TenantID, targetType string) ([]types.AbuseReason, error) { + if len(s.reasons) == 0 { + s.reasons = []types.AbuseReason{{TargetType: targetType, ReasonCode: "fraud", Label: "诈骗欺诈", AllowsEvidence: true}} + } + return s.reasons, nil +} + +func (s *testSafetyStore) CreateAbuseReport(_ types.TenantID, report *types.AbuseReport, _ string) (*types.AbuseReport, error) { + now := time.Now().UTC().Round(time.Millisecond) + report.Status = types.AbuseReportStatusReceived + report.CreatedAt = now + report.UpdatedAt = now + s.abuseReport = report + return report, nil +} + +func (s *testSafetyStore) FeedbackConfig(types.TenantID, types.Uid) (*types.FeedbackConfig, error) { + now := time.Now().UTC().Round(time.Millisecond) + return &types.FeedbackConfig{ + Categories: []string{"bug", "suggestion"}, + MaxLength: 1000, + AttachmentPolicy: map[string]any{"max_files": float64(5), "purpose": "feedback"}, + AllowContact: true, + DiagnosticsNotice: "仅在同意后上传诊断信息。", + UpdatedAt: now, + }, nil +} + +func (s *testSafetyStore) CreateFeedback(_ types.TenantID, feedback *types.FeedbackSubmission, _ string) (*types.FeedbackSubmission, error) { + now := time.Now().UTC().Round(time.Millisecond) + feedback.Status = types.FeedbackStatusReceived + feedback.CreatedAt = now + feedback.UpdatedAt = now + s.feedback = feedback + return feedback, nil +} + func (s *testCallsStore) Create(_ types.TenantID, _ *types.CallRecord) error { return nil } @@ -2435,6 +2501,7 @@ func TestServeIMSecurityURLCheck(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store + oldSafety := store.Safety mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2) @@ -2442,7 +2509,11 @@ func TestServeIMSecurityURLCheck(t *testing.T) { tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(2) store.Store = mockStore - defer func() { store.Store = oldStore }() + store.Safety = &testSafetyStore{} + defer func() { + store.Store = oldStore + store.Safety = oldSafety + }() allowReq := httptest.NewRequest(http.MethodPost, "/v1/im/security/urls/check", bytes.NewBufferString(`{"url":"https://example.com/a","source":"message"}`)) allowReq.Header.Set("Authorization", "Bearer token-1") @@ -2467,6 +2538,106 @@ func TestServeIMSecurityURLCheck(t *testing.T) { } } +func TestServeIMRiskChallengeConfirm(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldSafety := store.Safety + oldSecurity := store.Security + now := time.Now().UTC().Round(time.Millisecond) + store.Safety = &testSafetyStore{riskChallenge: &types.RiskChallenge{ + ChallengeID: "risk-1", UserID: 42, Action: "open_url", Level: "high", NoticeCode: "url_high", + State: types.RiskChallengeStatePending, ExpiresAt: now.Add(time.Minute), CreatedAt: now, UpdatedAt: now, + }} + store.Security = &testSecurityStore{} + defer func() { + store.Safety = oldSafety + store.Security = oldSecurity + }() + + req := httptest.NewRequest(http.MethodPost, "/v1/im/security/risk-challenges/risk-1/confirm", bytes.NewBufferString(`{"decision":"continue"}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMRiskChallengeConfirm(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["state"] != types.RiskChallengeStateContinued || data["risk_action_token"] == "" { + t.Fatalf("unexpected risk confirm response: %#v", data) + } +} + +func TestServeIMAbuseReportsAndReasons(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldSafety := store.Safety + oldUsers := store.Users + safety := &testSafetyStore{} + store.Safety = safety + users := mock_store.NewMockUsersPersistenceInterface(ctrl) + store.Users = users + users.EXPECT().Get(types.TenantID(7), types.Uid(43)).Return(&types.User{}, nil).AnyTimes() + defer func() { + store.Safety = oldSafety + store.Users = oldUsers + }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/reports/abuse/reasons?target_type=user", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMAbuseReasons(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("reasons status: got %d body=%s", rec.Code, rec.Body.String()) + } + + req = httptest.NewRequest(http.MethodPost, "/v1/im/reports/abuse", + bytes.NewBufferString(`{"target":{"type":"user","user_id":"usrKwAAAAAAAAA"},"reason_code":"fraud","description":"疑似诈骗","evidence_file_ids":["file1"]}`)) + req.Header.Set("Authorization", "Bearer token-1") + req.Header.Set("Idempotency-Key", "abuse-1") + rec = httptest.NewRecorder() + serveIMAbuseReports(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("report status: got %d body=%s", rec.Code, rec.Body.String()) + } + if safety.abuseReport == nil || safety.abuseReport.TargetUserID != 43 || safety.abuseReport.ReasonCode != "fraud" { + t.Fatalf("unexpected abuse report: %#v", safety.abuseReport) + } +} + +func TestServeIMFeedback(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + expectIMBearer(t, ctrl, "token-1", 7, 42) + oldSafety := store.Safety + safety := &testSafetyStore{} + store.Safety = safety + defer func() { store.Safety = oldSafety }() + + req := httptest.NewRequest(http.MethodGet, "/v1/im/feedback/config", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + serveIMFeedbackConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("config status: got %d body=%s", rec.Code, rec.Body.String()) + } + + req = httptest.NewRequest(http.MethodPost, "/v1/im/feedback", + bytes.NewBufferString(`{"category":"bug","content":"页面显示异常","contact":"me@example.com","attachment_file_ids":["file1"],"client_context":{"platform":"android"},"include_diagnostics":true}`)) + req.Header.Set("Authorization", "Bearer token-1") + req.Header.Set("Idempotency-Key", "feedback-1") + rec = httptest.NewRecorder() + serveIMFeedback(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("submit status: got %d body=%s", rec.Code, rec.Body.String()) + } + if safety.feedback == nil || safety.feedback.Category != "bug" || !safety.feedback.IncludeDiagnostics { + t.Fatalf("unexpected feedback: %#v", safety.feedback) + } +} + func TestServeIMMessageSearchContextAndMedia(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/server/main.go b/server/main.go index 9221504..7e82f28 100644 --- a/server/main.go +++ b/server/main.go @@ -783,6 +783,7 @@ func main() { mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/challenges/", serveIMPasswordResetVerify) mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/confirm", serveIMPasswordResetConfirm) mux.HandleFunc(config.ApiPath+"v1/im/security/urls/check", serveIMSecurityURLCheck) + mux.HandleFunc(config.ApiPath+"v1/im/security/risk-challenges/", serveIMRiskChallengeConfirm) mux.HandleFunc(config.ApiPath+"v1/im/security/challenges", serveIMSecurityChallenges) mux.HandleFunc(config.ApiPath+"v1/im/security/challenges/", serveIMSecurityChallengeVerify) mux.HandleFunc(config.ApiPath+"v1/im/security/realname", serveIMRealName) @@ -794,6 +795,8 @@ func main() { mux.HandleFunc(config.ApiPath+"v1/im/organization/search", serveIMOrganizationSearch) mux.HandleFunc(config.ApiPath+"v1/im/meetings", serveIMMeetings) mux.HandleFunc(config.ApiPath+"v1/im/meetings/", serveIMMeetingResource) + mux.HandleFunc(config.ApiPath+"v1/im/reports/abuse/reasons", serveIMAbuseReasons) + mux.HandleFunc(config.ApiPath+"v1/im/reports/abuse", serveIMAbuseReports) mux.HandleFunc(config.ApiPath+"v1/im/reports/daily/statistics", serveIMDailyReportStatistics) mux.HandleFunc(config.ApiPath+"v1/im/reports/daily/reminders", serveIMDailyReportReminders) mux.HandleFunc(config.ApiPath+"v1/im/reports/daily", serveIMDailyReports) @@ -803,6 +806,8 @@ func main() { mux.HandleFunc(config.ApiPath+"v1/im/reports/monthly/", serveIMMonthlyReportResource) mux.HandleFunc(config.ApiPath+"v1/im/executive-mailbox/config", serveIMMailboxConfig) mux.HandleFunc(config.ApiPath+"v1/im/executive-mailbox/submissions", serveIMMailboxSubmissions) + mux.HandleFunc(config.ApiPath+"v1/im/feedback/config", serveIMFeedbackConfig) + mux.HandleFunc(config.ApiPath+"v1/im/feedback", serveIMFeedback) mux.HandleFunc(config.ApiPath+"v1/im/app/releases/latest", serveIMAppReleaseLatest) mux.HandleFunc(config.ApiPath+"v1/im/me", serveIMMe) mux.HandleFunc(config.ApiPath+"v1/im/me/profile", serveIMMeProfile) diff --git a/server/store/store.go b/server/store/store.go index a71fbd3..d1c28ca 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -1520,6 +1520,95 @@ func (devicePushTokenMapper) Upsert(tenantID types.TenantID, token *types.Device return tenantAdp.TenantDevicePushTokenUpsert(tenantID, token) } +type SafetyPersistenceInterface interface { + CreateRiskChallenge(tenantID types.TenantID, challenge *types.RiskChallenge) (*types.RiskChallenge, error) + ConfirmRiskChallenge(tenantID types.TenantID, uid types.Uid, challengeID, decision, actionTokenID string, now time.Time) (*types.RiskChallenge, error) + AbuseReasons(tenantID types.TenantID, targetType string) ([]types.AbuseReason, error) + CreateAbuseReport(tenantID types.TenantID, report *types.AbuseReport, idempotencyKey string) (*types.AbuseReport, error) + FeedbackConfig(tenantID types.TenantID, uid types.Uid) (*types.FeedbackConfig, error) + CreateFeedback(tenantID types.TenantID, feedback *types.FeedbackSubmission, idempotencyKey string) (*types.FeedbackSubmission, error) +} + +type safetyMapper struct{} + +var Safety SafetyPersistenceInterface + +func (safetyMapper) CreateRiskChallenge(tenantID types.TenantID, challenge *types.RiskChallenge) (*types.RiskChallenge, error) { + if challenge == nil || challenge.UserID.IsZero() || strings.TrimSpace(challenge.ChallengeID) == "" || + strings.TrimSpace(challenge.Action) == "" || challenge.ExpiresAt.IsZero() { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantRiskChallengeCreate(tenantID, challenge) +} + +func (safetyMapper) ConfirmRiskChallenge(tenantID types.TenantID, uid types.Uid, challengeID, decision, actionTokenID string, now time.Time) (*types.RiskChallenge, error) { + decision = strings.ToLower(strings.TrimSpace(decision)) + if uid.IsZero() || strings.TrimSpace(challengeID) == "" || (decision != "continue" && decision != "cancel") { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantRiskChallengeConfirm(tenantID, uid, strings.TrimSpace(challengeID), decision, strings.TrimSpace(actionTokenID), now) +} + +func (safetyMapper) AbuseReasons(tenantID types.TenantID, targetType string) ([]types.AbuseReason, error) { + targetType = strings.ToLower(strings.TrimSpace(targetType)) + if targetType == "" { + targetType = "message" + } + if targetType != "user" && targetType != "group" && targetType != "message" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantAbuseReasons(tenantID, targetType) +} + +func (safetyMapper) CreateAbuseReport(tenantID types.TenantID, report *types.AbuseReport, idempotencyKey string) (*types.AbuseReport, error) { + if report == nil || report.ReporterID.IsZero() || strings.TrimSpace(report.ReportID) == "" || + strings.TrimSpace(report.TargetType) == "" || strings.TrimSpace(report.ReasonCode) == "" || + strings.TrimSpace(idempotencyKey) == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantAbuseReportCreate(tenantID, report, strings.TrimSpace(idempotencyKey)) +} + +func (safetyMapper) FeedbackConfig(tenantID types.TenantID, uid types.Uid) (*types.FeedbackConfig, error) { + if uid.IsZero() { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantFeedbackConfigGet(tenantID, uid) +} + +func (safetyMapper) CreateFeedback(tenantID types.TenantID, feedback *types.FeedbackSubmission, idempotencyKey string) (*types.FeedbackSubmission, error) { + if feedback == nil || feedback.UserID.IsZero() || strings.TrimSpace(feedback.FeedbackID) == "" || + strings.TrimSpace(feedback.Category) == "" || strings.TrimSpace(feedback.Content) == "" || + strings.TrimSpace(idempotencyKey) == "" { + return nil, types.ErrMalformed + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantFeedbackCreate(tenantID, feedback, strings.TrimSpace(idempotencyKey)) +} + func (profileMapper) Get(tenantID types.TenantID, uid types.Uid) (*types.UserProfile, error) { if uid.IsZero() { return nil, types.ErrMalformed @@ -2781,6 +2870,7 @@ func init() { Profiles = profileMapper{} AppReleases = appReleaseMapper{} DevicePushTokens = devicePushTokenMapper{} + Safety = safetyMapper{} Devices = deviceMapper{} Files = fileMapper{} PCache = pcacheMapper{} diff --git a/server/store/types/safety.go b/server/store/types/safety.go new file mode 100644 index 0000000..efc9f5a --- /dev/null +++ b/server/store/types/safety.go @@ -0,0 +1,86 @@ +package types + +import "time" + +const ( + RiskChallengeStatePending = "pending" + RiskChallengeStateContinued = "continued" + RiskChallengeStateCanceled = "canceled" + RiskChallengeStateExpired = "expired" + + AbuseReportStatusReceived = "received" + FeedbackStatusReceived = "received" +) + +type RiskChallenge struct { + ID int64 + ChallengeID string + UserID Uid + Action string + Level string + NoticeCode string + NoticeParams map[string]any + State string + ExpiresAt time.Time + Decision string + ActionTokenID string + CreatedAt time.Time + UpdatedAt time.Time +} + +type AbuseReason struct { + TargetType string + ReasonCode string + Label string + RequiresDescription bool + AllowsEvidence bool + SortOrder int +} + +type AbuseTarget struct { + Type string `json:"type"` + UserID string `json:"user_id,omitempty"` + Topic string `json:"topic,omitempty"` + Seq string `json:"seq,omitempty"` +} + +type AbuseReport struct { + ID int64 + ReportID string + ReporterID Uid + TargetType string + TargetUserID Uid + TargetTopic string + TargetSeq int + ReasonCode string + Description string + EvidenceFileIDs []string + Snapshot map[string]any + Status string + CreatedAt time.Time + UpdatedAt time.Time +} + +type FeedbackConfig struct { + Categories []string + MaxLength int + AttachmentPolicy map[string]any + AllowContact bool + DiagnosticsNotice string + UpdatedAt time.Time +} + +type FeedbackSubmission struct { + ID int64 + FeedbackID string + UserID Uid + Category string + Content string + Contact string + AttachmentFileIDs []string + ClientContext map[string]any + IncludeDiagnostics bool + Status string + CreatedAt time.Time + UpdatedAt time.Time +}