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
+26 -3
View File
@@ -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:租户策略配置、系统通知生产通道、实名审核、设备管理、资料隐私裁剪、关系审计、群禁言发送校验、群文件索引、通话质量指标、会议/日报/月报/信箱完整业务表。
+105
View File
@@ -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)
}
+6
View File
@@ -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)
+85
View File
@@ -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 {
+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"`
+376 -2
View File
@@ -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)
+172 -1
View File
@@ -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()
+5
View File
@@ -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)
+90
View File
@@ -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{}
+86
View File
@@ -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
}