feat: add safety reporting APIs
This commit is contained in:
+26
-3
@@ -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:租户策略配置、系统通知生产通道、实名审核、设备管理、资料隐私裁剪、关系审计、群禁言发送校验、群文件索引、通话质量指标、会议/日报/月报/信箱完整业务表。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user