feat: add safety reporting APIs
This commit is contained in:
+26
-3
@@ -1,7 +1,7 @@
|
|||||||
# IM 第一至九章后续增强项
|
# IM 第一至十一章后续增强项
|
||||||
|
|
||||||
> 日期:2026-08-13
|
> 日期:2026-08-13
|
||||||
> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章群消息与 @、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构
|
> 范围:第一章登录注册与安全、第二章消息首页/导航/系统通知、第三章单聊关系、第四章群消息与 @、第五章通讯录/好友/黑名单、第六章群聊创建/资料/治理、第七章文件/收藏/聊天记录检索、第八章实时语音与视频通话、第九章企业工作台与组织架构、第十章个人设置与客户端能力、第十一章反诈安全/举报/反馈
|
||||||
> 用途:记录当前已完成基线之外的增强项,后续统一排期处理。
|
> 用途:记录当前已完成基线之外的增强项,后续统一排期处理。
|
||||||
|
|
||||||
## 1. 第一章:登录、注册与安全
|
## 1. 第一章:登录、注册与安全
|
||||||
@@ -267,7 +267,30 @@
|
|||||||
| P1 | 资料隐私裁剪落地 | 隐私设置需要接入用户搜索、组织架构、会话资料页等读路径。 | 非授权用户无法通过其他接口绕过隐私配置。 |
|
| P1 | 资料隐私裁剪落地 | 隐私设置需要接入用户搜索、组织架构、会话资料页等读路径。 | 非授权用户无法通过其他接口绕过隐私配置。 |
|
||||||
| P2 | 客户端能力上报 | 后续增加设备能力、通知权限、厂商通道状态和前台活跃状态上报。 | 后台可定位推送不可达和版本兼容问题。 |
|
| 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/好友/搜索隐私策略、第六章群业务独立表与事务状态机、文件下载签名、通话强状态机、工作台/组织权限。
|
1. 先做 P0:统一幂等、实时事件/Outbox、验证码真实发送与限频、P2P/好友/搜索隐私策略、第六章群业务独立表与事务状态机、文件下载签名、通话强状态机、工作台/组织权限。
|
||||||
2. 再做 P1:租户策略配置、系统通知生产通道、实名审核、设备管理、资料隐私裁剪、关系审计、群禁言发送校验、群文件索引、通话质量指标、会议/日报/月报/信箱完整业务表。
|
2. 再做 P1:租户策略配置、系统通知生产通道、实名审核、设备管理、资料隐私裁剪、关系审计、群禁言发送校验、群文件索引、通话质量指标、会议/日报/月报/信箱完整业务表。
|
||||||
|
|||||||
@@ -311,6 +311,91 @@ func main() {
|
|||||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
PRIMARY KEY(user_id, purpose)
|
PRIMARY KEY(user_id, purpose)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
) 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 {
|
for _, stmt := range statements {
|
||||||
if _, err = db.Exec(stmt); err != nil {
|
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 {
|
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)
|
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 {
|
if err = dropTenantIDColumns(db, "tinode"); err != nil {
|
||||||
fatal(err)
|
fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -325,6 +325,12 @@ type TenantBusinessAdapter interface {
|
|||||||
TenantUserPrivacyUpdate(t.TenantID, t.Uid, t.UserPrivacyPatch) (*t.UserPrivacySettings, error)
|
TenantUserPrivacyUpdate(t.TenantID, t.Uid, t.UserPrivacyPatch) (*t.UserPrivacySettings, error)
|
||||||
TenantAppReleaseLatest(t.TenantID, string, string) (*t.AppRelease, error)
|
TenantAppReleaseLatest(t.TenantID, string, string) (*t.AppRelease, error)
|
||||||
TenantDevicePushTokenUpsert(t.TenantID, *t.DevicePushToken) (*t.DevicePushToken, 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)
|
TenantFavoriteCreate(t.TenantID, *t.FavoriteItem) (*t.FavoriteItem, error)
|
||||||
TenantFavoriteList(t.TenantID, t.Uid, string, string, string, int) ([]t.FavoriteItem, string, 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),
|
UNIQUE INDEX uk_im_device_push_tokens_provider_token(provider, token),
|
||||||
INDEX idx_im_device_push_tokens_user(user_id, updated_at)
|
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 {
|
for _, stmt := range statements {
|
||||||
if _, err := tx.Exec(stmt); err != nil {
|
if _, err := tx.Exec(stmt); err != nil {
|
||||||
|
|||||||
@@ -3890,6 +3890,399 @@ func (a *adapter) TenantDevicePushTokenUpsert(id t.TenantID, token *t.DevicePush
|
|||||||
return token, nil
|
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) {
|
func (a *adapter) userProfileByUID(ctx context.Context, uid t.Uid) (*t.UserProfile, error) {
|
||||||
row := struct {
|
row := struct {
|
||||||
UserID int64 `db:"user_id"`
|
UserID int64 `db:"user_id"`
|
||||||
|
|||||||
+376
-2
@@ -40,6 +40,7 @@ const (
|
|||||||
imReasonRealNameRequired = "REALNAME_REQUIRED"
|
imReasonRealNameRequired = "REALNAME_REQUIRED"
|
||||||
imReasonActionToken = "ACTION_TOKEN_INVALID"
|
imReasonActionToken = "ACTION_TOKEN_INVALID"
|
||||||
imReasonPermissionDenied = "PERMISSION_DENIED"
|
imReasonPermissionDenied = "PERMISSION_DENIED"
|
||||||
|
imReasonRiskConfirmation = "RISK_CONFIRMATION_REQUIRED"
|
||||||
imReasonInternal = "INTERNAL_ERROR"
|
imReasonInternal = "INTERNAL_ERROR"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,6 +134,26 @@ type imDevicePushTokenRequest struct {
|
|||||||
Platform string `json:"platform,omitempty"`
|
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 {
|
type imSystemNotificationReadRequest struct {
|
||||||
ReadAt string `json:"read_at,omitempty"`
|
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)
|
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := authenticateIMHTTPBearer(wrt, req); !ok {
|
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
||||||
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var body imURLCheckRequest
|
var body imURLCheckRequest
|
||||||
@@ -679,11 +701,208 @@ func serveIMSecurityURLCheck(wrt http.ResponseWriter, req *http.Request) {
|
|||||||
"reason": reason,
|
"reason": reason,
|
||||||
}
|
}
|
||||||
if decision != "allow" {
|
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)
|
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) {
|
func serveIMSecurityChallenges(wrt http.ResponseWriter, req *http.Request) {
|
||||||
if req.Method != http.MethodPost {
|
if req.Method != http.MethodPost {
|
||||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
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 {
|
func isAllowedIMWorkbenchRoute(route string) bool {
|
||||||
switch route {
|
switch route {
|
||||||
case "/organization", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox":
|
case "/organization", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox":
|
||||||
@@ -6228,6 +6498,110 @@ func securityChallengeIDFromPath(path string) string {
|
|||||||
return strings.TrimSpace(challengeID)
|
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 {
|
func systemNotificationIDFromPath(path string) string {
|
||||||
const marker = "/v1/im/system-notifications/"
|
const marker = "/v1/im/system-notifications/"
|
||||||
idx := strings.Index(path, marker)
|
idx := strings.Index(path, marker)
|
||||||
|
|||||||
+172
-1
@@ -112,6 +112,13 @@ type testDevicePushTokenStore struct {
|
|||||||
token *types.DevicePushToken
|
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) {
|
func (s *testWorkbenchStore) Get(_ types.TenantID, _ types.Uid) ([]types.WorkbenchModule, int, error) {
|
||||||
if s.version == 0 {
|
if s.version == 0 {
|
||||||
s.version = 1
|
s.version = 1
|
||||||
@@ -370,6 +377,65 @@ func (s *testDevicePushTokenStore) Upsert(_ types.TenantID, token *types.DeviceP
|
|||||||
return token, nil
|
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 {
|
func (s *testCallsStore) Create(_ types.TenantID, _ *types.CallRecord) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -2435,6 +2501,7 @@ func TestServeIMSecurityURLCheck(t *testing.T) {
|
|||||||
ctrl := gomock.NewController(t)
|
ctrl := gomock.NewController(t)
|
||||||
defer ctrl.Finish()
|
defer ctrl.Finish()
|
||||||
oldStore := store.Store
|
oldStore := store.Store
|
||||||
|
oldSafety := store.Safety
|
||||||
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
||||||
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
|
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
|
||||||
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2)
|
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")).
|
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")).
|
||||||
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(2)
|
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(2)
|
||||||
store.Store = mockStore
|
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 := 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")
|
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) {
|
func TestServeIMMessageSearchContextAndMedia(t *testing.T) {
|
||||||
ctrl := gomock.NewController(t)
|
ctrl := gomock.NewController(t)
|
||||||
defer ctrl.Finish()
|
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/challenges/", serveIMPasswordResetVerify)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/confirm", serveIMPasswordResetConfirm)
|
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/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", serveIMSecurityChallenges)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/security/challenges/", serveIMSecurityChallengeVerify)
|
mux.HandleFunc(config.ApiPath+"v1/im/security/challenges/", serveIMSecurityChallengeVerify)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/security/realname", serveIMRealName)
|
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/organization/search", serveIMOrganizationSearch)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/meetings", serveIMMeetings)
|
mux.HandleFunc(config.ApiPath+"v1/im/meetings", serveIMMeetings)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/meetings/", serveIMMeetingResource)
|
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/statistics", serveIMDailyReportStatistics)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/reports/daily/reminders", serveIMDailyReportReminders)
|
mux.HandleFunc(config.ApiPath+"v1/im/reports/daily/reminders", serveIMDailyReportReminders)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/reports/daily", serveIMDailyReports)
|
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/reports/monthly/", serveIMMonthlyReportResource)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/executive-mailbox/config", serveIMMailboxConfig)
|
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/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/app/releases/latest", serveIMAppReleaseLatest)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/me", serveIMMe)
|
mux.HandleFunc(config.ApiPath+"v1/im/me", serveIMMe)
|
||||||
mux.HandleFunc(config.ApiPath+"v1/im/me/profile", serveIMMeProfile)
|
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)
|
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) {
|
func (profileMapper) Get(tenantID types.TenantID, uid types.Uid) (*types.UserProfile, error) {
|
||||||
if uid.IsZero() {
|
if uid.IsZero() {
|
||||||
return nil, types.ErrMalformed
|
return nil, types.ErrMalformed
|
||||||
@@ -2781,6 +2870,7 @@ func init() {
|
|||||||
Profiles = profileMapper{}
|
Profiles = profileMapper{}
|
||||||
AppReleases = appReleaseMapper{}
|
AppReleases = appReleaseMapper{}
|
||||||
DevicePushTokens = devicePushTokenMapper{}
|
DevicePushTokens = devicePushTokenMapper{}
|
||||||
|
Safety = safetyMapper{}
|
||||||
Devices = deviceMapper{}
|
Devices = deviceMapper{}
|
||||||
Files = fileMapper{}
|
Files = fileMapper{}
|
||||||
PCache = pcacheMapper{}
|
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