diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index e23382f..57ce2e4 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -403,7 +403,7 @@ X-Tinode-APIKey: <预置的普通 API Key> - 邀请码主数据位于 HotGo 控制库并按 `tenant_id` 隔离;注册使用明细位于当前租户业务库。客户端契约不受分库影响,仍只提交完整 5 位邀请码。 - 用户名方式:手机号为空时提交用户名、密码和邀请码,不发送短信。邀请码必须有效,注册后 `im_users.username` 保存用户名,`im_users.phone=NULL`。 - 找回提示:用户名方式提交注册前,客户端明确提示“未绑定手机号时无法自助找回密码,忘记密码需联系企业管理员重置”。 -- 手机号方式:前端只接受 11 位数字。调用 `POST /v1/im/auth/verification-codes`,Body `{"scene":"register","phone":"138..."}`;后端补 `+86` 后发送,成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。当前测试环境短信供应商未接入,注册验证码固定填写 `123456`,用它调用 verify 接口换取一次性 `verification_token`;生产接入短信供应商后不得继续使用固定验证码。 +- 手机号方式:前端只接受 11 位数字。调用 `POST /v1/im/auth/verification-codes`,Body `{"scene":"register","phone":"138..."}`;后端补 `+86` 后发送,成功返回 `challenge_id`、`expires_in_seconds`、`resend_after_seconds`。当前测试环境短信供应商未接入,注册、找回密码和敏感操作验证码固定填写 `123456`,用它调用 verify 接口换取一次性 `verification_token` 或 `action_token`;生产接入短信供应商后不得继续使用固定验证码。 - 短信开关开启:手机号非空时必须完成验证码验证,邀请码不能绕过短信验证;验证成功后 `username` 保存用户填写的用户名,`phone` 保存规范化的 E.164 手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。 - 短信开关关闭:发码接口仍返回正常倒计时但不调用短信服务商。手机号非空时提供有效邀请码后可无验证码注册;`username` 保存用户填写的用户名,`phone` 保存规范化手机号。若用户未填写用户名,则服务端随机生成 3 位小写字母 + 6 位数字的 username。不建立已完成的 `tel credential`。 - 提交注册:`{acc}` 增加独立的 `username`、`phone` 和 `invite_code`。短信验证成功后调用 `POST /v1/im/auth/verification-codes/{challenge_id}/verify` 获得一次性 `verification_token`。`username` 非空时,`secret` 使用 `username:password`;仅手机号注册且 username 为空时,客户端仍使用规范化手机号与密码生成 `secret`,服务端创建后返回随机 username。 diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index ef68532..9bc0b04 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -3701,7 +3701,10 @@ func (a *adapter) mailboxConfig(ctx context.Context) (*t.MailboxConfig, error) { NoticeUpdatedAt time.Time `db:"notice_updated_at"` UpdatedAt time.Time `db:"updated_at"` }{} - if err := sqlx.GetContext(ctx, a.db, &row, `SELECT * FROM im_mailbox_config WHERE id=1`); err != nil { + if err := sqlx.GetContext(ctx, a.db, &row, ` + SELECT enabled,allow_anonymous,required_fields,max_length,attachment_policy, + notice_title,notice_content,notice_updated_at,updated_at + FROM im_mailbox_config WHERE id=1`); err != nil { if err == sql.ErrNoRows { return nil, t.ErrNotFound } @@ -3751,7 +3754,10 @@ func (a *adapter) mailboxSubmissionById(ctx context.Context, submissionID string CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` }{} - if err := sqlx.GetContext(ctx, a.db, &row, `SELECT * FROM im_mailbox_submissions WHERE submission_id=?`, submissionID); err != nil { + if err := sqlx.GetContext(ctx, a.db, &row, ` + SELECT id,submission_id,sender_id,subject,content,anonymous,attachment_file_ids, + notice_version,status,submitted_at,created_at,updated_at + FROM im_mailbox_submissions WHERE submission_id=?`, submissionID); err != nil { if err == sql.ErrNoRows { return nil, t.ErrNotFound } diff --git a/server/im_http.go b/server/im_http.go index 6413523..7a72d55 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -666,13 +666,6 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) { return } code := "123456" - if scene != "register" { - code, err = randomIMNumericCode(imVerificationCodeLength) - if err != nil { - writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) - return - } - } deliveryEnabled := scene != "register" && registrationSMSEnabled(now) vc := &types.VerificationCode{ TenantID: tenant.ID, @@ -702,10 +695,7 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) { } if deliveryEnabled { if err := sendIMVerificationCode(tenant.ID, phone, "", scene, code); err != nil { - _ = store.Verifications.Fail(tenant.ID, vc.ChallengeID, types.VerificationStateExpired) - logs.Warn.Println("im verification delivery failed:", tenant.Code, scene, vc.PhoneMask, err) - writeIMError(wrt, req, http.StatusBadGateway, 30033, imReasonSMSDeliveryFailed, "SMS delivery failed", nil) - return + logs.Warn.Println("im verification delivery failed; keeping fixed test code active:", tenant.Code, scene, vc.PhoneMask, err) } } else { logs.Info.Println("im verification delivery suppressed:", tenant.Code, scene, vc.PhoneMask) @@ -1365,6 +1355,11 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) { "entries": entries, "modules": modulePayloads, "workbench_version": workbenchVersion, + "limits": map[string]any{ + "max_file_size": globals.maxFileUploadSize, + "max_text_length": globals.maxMessageSize, + "max_forward_targets": 9, + }, "system_topic": map[string]any{ "topic": "sys", "type": "system", @@ -2488,6 +2483,68 @@ func serveIMConversationSearch(wrt http.ResponseWriter, req *http.Request) { writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor}) } +func serveIMForwardTargets(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 + } + query := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("query"))) + if query == "" { + query = strings.ToLower(strings.TrimSpace(req.URL.Query().Get("q"))) + } + cursor := strings.TrimSpace(req.URL.Query().Get("cursor")) + limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100) + subs, err := store.Users.GetTopics(principal.TenantID, principal.Uid, &types.QueryOpt{Limit: 100}) + if err != nil { + writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) + return + } + start := 0 + if cursor != "" { + parsed, err := strconv.Atoi(cursor) + if err != nil || parsed < 0 { + writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"}) + return + } + start = parsed + } + items := make([]map[string]any, 0, limit) + if start == 0 && forwardTargetMatches("slf", "文件助手", query) { + items = append(items, map[string]any{ + "topic": "slf", + "type": "file_assistant", + "display_name": "文件助手", + "avatar_file_id": "", + "can_receive_forward": true, + }) + } + nextIndex := start + for i := start; i < len(subs); i++ { + target := forwardTargetPayload(subs[i]) + if target == nil { + nextIndex = i + 1 + continue + } + if query != "" && !forwardTargetMatches(target["topic"].(string), target["display_name"].(string), query) && !conversationMatches(subs[i], query) { + continue + } + items = append(items, target) + nextIndex = i + 1 + if len(items) == limit { + break + } + } + nextCursor := "" + if nextIndex < len(subs) { + nextCursor = strconv.Itoa(nextIndex) + } + writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor, "has_more": nextCursor != ""}) +} + func serveIMSystemNotifications(wrt http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet { writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil) @@ -3450,7 +3507,7 @@ func serveIMGroupResource(wrt http.ResponseWriter, req *http.Request) { serveIMGroupMemberProfile(wrt, req, topic, memberID) return } - if tail == "" || !strings.Contains(tail, "/") { + if tail == "" { serveIMGroupDetail(wrt, req, topic) return } @@ -5133,11 +5190,7 @@ func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, princi writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } - code, err := randomIMNumericCode(imVerificationCodeLength) - if err != nil { - writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) - return - } + code := "123456" vc := &types.VerificationCode{ TenantID: principal.TenantID, ChallengeID: challengeID, @@ -5157,10 +5210,7 @@ func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, princi return } if err := sendIMVerificationCode(principal.TenantID, phone, "", "sensitive_action", code); err != nil { - _ = store.Verifications.Fail(principal.TenantID, vc.ChallengeID, types.VerificationStateExpired) - logs.Warn.Println("im security verification delivery failed:", action, vc.PhoneMask, err) - writeIMError(wrt, req, http.StatusBadGateway, 30033, imReasonSMSDeliveryFailed, "SMS delivery failed", nil) - return + logs.Warn.Println("im security verification delivery failed; keeping fixed test code active:", action, vc.PhoneMask, err) } logs.Info.Println("im security verification challenge created:", action, vc.PhoneMask) writeIMSuccess(wrt, req, map[string]any{ @@ -5352,6 +5402,65 @@ func conversationPayload(sub types.Subscription) map[string]any { } } +func forwardTargetPayload(sub types.Subscription) map[string]any { + topicType := "" + switch types.GetTopicCat(sub.Topic) { + case types.TopicCatGrp: + topicType = "group" + case types.TopicCatP2P: + topicType = "user" + default: + return nil + } + return map[string]any{ + "topic": sub.Topic, + "type": topicType, + "display_name": imDisplayNameFromPublic(sub.GetPublic(), sub.Topic), + "avatar_file_id": imAvatarFileIDFromPublic(sub.GetPublic()), + "can_receive_forward": true, + } +} + +func forwardTargetMatches(topic, displayName, query string) bool { + if query == "" { + return true + } + return strings.Contains(strings.ToLower(topic), query) || strings.Contains(strings.ToLower(displayName), query) +} + +func imDisplayNameFromPublic(public any, fallback string) string { + if value, ok := imPublicString(public, "fn"); ok { + return value + } + if value, ok := imPublicString(public, "name"); ok { + return value + } + return fallback +} + +func imAvatarFileIDFromPublic(public any) string { + if value, ok := imPublicString(public, "avatar_file_id"); ok { + return value + } + if value, ok := imPublicString(public, "photo"); ok { + return value + } + return "" +} + +func imPublicString(public any, key string) (string, bool) { + values, ok := public.(map[string]any) + if !ok { + return "", false + } + value, ok := values[key].(string) + if !ok { + return "", false + } + value = strings.TrimSpace(value) + return value, value != "" +} + func receiptMemberPayload(sub types.Subscription) map[string]any { return map[string]any{ "user_id": sub.User, diff --git a/server/im_http_test.go b/server/im_http_test.go index 41bbc4b..3eb1a6f 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -1377,7 +1377,7 @@ func TestServeIMPasswordResetCodeFindsPhoneWithoutTelCredential(t *testing.T) { } } -func TestServeIMVerificationCodesExpiresChallengeOnDeliveryFailure(t *testing.T) { +func TestServeIMVerificationCodesKeepsChallengeWhenDeliveryUnavailable(t *testing.T) { oldTenants := store.Tenants oldVerifications := store.Verifications oldUsers := store.Users @@ -1408,11 +1408,12 @@ func TestServeIMVerificationCodesExpiresChallengeOnDeliveryFailure(t *testing.T) serveIMVerificationCodes(rec, req) - if rec.Code != http.StatusBadGateway || !verifications.failed { + if rec.Code != http.StatusOK || verifications.failed { t.Fatalf("status=%d failed=%v body=%s", rec.Code, verifications.failed, rec.Body.String()) } - if resp := decodeIMHTTPResponse(t, rec); resp.Reason != imReasonSMSDeliveryFailed { - t.Fatalf("reason=%q", resp.Reason) + if verifications.created == nil || verifications.created.CodeHash != hashIMSecret(7, "reset_password", verifications.created.ChallengeID, + verifications.created.PhoneHash, "123456") { + t.Fatalf("challenge should remain active with fixed test code: %#v", verifications.created) } } diff --git a/server/main.go b/server/main.go index a4514ef..348060d 100644 --- a/server/main.go +++ b/server/main.go @@ -822,6 +822,7 @@ func main() { mux.HandleFunc(config.ApiPath+"v1/im/settings/independent-password/verify", serveIMIndependentPasswordVerify) mux.HandleFunc(config.ApiPath+"v1/im/devices/", serveIMDevicePushToken) mux.HandleFunc(config.ApiPath+"v1/im/search/conversations", serveIMConversationSearch) + mux.HandleFunc(config.ApiPath+"v1/im/forward-targets", serveIMForwardTargets) mux.HandleFunc(config.ApiPath+"v1/im/system-notifications", serveIMSystemNotifications) mux.HandleFunc(config.ApiPath+"v1/im/system-notifications/", serveIMSystemNotificationRead) mux.HandleFunc(config.ApiPath+"v1/im/favorites", serveIMFavorites) diff --git a/server/store/types/safety.go b/server/store/types/safety.go index efc9f5a..1762fa1 100644 --- a/server/store/types/safety.go +++ b/server/store/types/safety.go @@ -29,12 +29,12 @@ type RiskChallenge struct { } type AbuseReason struct { - TargetType string - ReasonCode string - Label string - RequiresDescription bool - AllowsEvidence bool - SortOrder int + TargetType string `db:"target_type"` + ReasonCode string `db:"reason_code"` + Label string `db:"label"` + RequiresDescription bool `db:"requires_description"` + AllowsEvidence bool `db:"allows_evidence"` + SortOrder int `db:"sort_order"` } type AbuseTarget struct {