From ae9a71034d73cb502564a45cc374401823164d79 Mon Sep 17 00:00:00 2001 From: zhr Date: Thu, 17 Sep 2026 19:52:11 +0800 Subject: [PATCH] feat: implement Spark IM V3 modular monolith Add HTTP /v1 MVC, TCP send path, groups with server-side member-avatar collage, object-storage upload, and friends/favorites around the architecture-v3 layout. Co-authored-by: Cursor --- .cursor/rules/http-mvc.mdc | 16 + .cursor/rules/message-deliver.mdc | 2 + .cursor/rules/module-layers.mdc | 19 + .cursor/skills/spark-im-v3/SKILL.md | 73 +- .cursor/skills/spark-im-v3/http.md | 105 ++ .cursor/skills/spark-im-v3/i18n.md | 57 + .dockerignore | 16 + .gitignore | 1 + .golangci.yaml | 2 +- AGENTS.md | 3 +- Dockerfile | 25 + Makefile | 14 +- README.md | 13 +- cmd/migrate/main.go | 129 ++ configs/config.yaml | 44 +- deploy/docker/config.docker.yaml | 93 ++ deploy/docker/deploy.sh | 28 + deploy/docker/docker-compose.yaml | 24 + deploy/nginx/README.md | 30 + deploy/nginx/host/spark-acme.conf | 15 + deploy/nginx/host/spark-sni.conf | 29 + deploy/nginx/host/sparkapi.conf | 77 ++ deploy/nginx/http.conf | 14 + deploy/nginx/install-host.sh | 76 ++ deploy/nginx/nginx.conf | 10 + deploy/nginx/stream.conf | 12 +- docs/architecture-v3.md | 30 +- examples/android/.gitignore | 5 + examples/android/app/build.gradle.kts | 32 + .../android/app/src/main/AndroidManifest.xml | 21 + .../java/com/spark/check/CheckActivity.kt | 88 ++ .../main/java/com/spark/check/SparkClient.kt | 174 +++ .../app/src/main/res/drawable/ic_launcher.xml | 5 + .../main/res/xml/network_security_config.xml | 8 + examples/android/build.gradle.kts | 4 + examples/android/gradle.properties | 4 + .../android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43504 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + examples/android/gradlew | 251 ++++ examples/android/gradlew.bat | 94 ++ examples/android/settings.gradle.kts | 16 + go.mod | 43 +- go.sum | 76 +- internal/bootstrap/app.go | 332 ++++- internal/bootstrap/config.go | 202 ++- internal/bootstrap/health.go | 95 ++ internal/bootstrap/health_test.go | 85 ++ internal/infrastructure/id/id.go | 5 + internal/infrastructure/logger/body.go | 20 + internal/infrastructure/logger/logger.go | 65 +- internal/infrastructure/logger/logger_test.go | 43 + internal/infrastructure/logger/options.go | 137 ++ internal/infrastructure/mysql/gorm_logger.go | 102 ++ .../infrastructure/mysql/gorm_logger_test.go | 60 + internal/infrastructure/mysql/mysql.go | 115 +- internal/infrastructure/objectstore/ops.go | 157 +++ internal/infrastructure/objectstore/policy.go | 85 ++ internal/infrastructure/objectstore/store.go | 109 ++ .../infrastructure/objectstore/store_test.go | 104 ++ internal/infrastructure/push/push.go | 2 + internal/infrastructure/redis/redis.go | 31 +- internal/infrastructure/redis/redis_test.go | 14 + internal/module/auth/entity/principal.go | 23 + internal/module/auth/logic/denylist.go | 91 ++ internal/module/auth/logic/jwt.go | 308 +++++ internal/module/auth/logic/jwt_test.go | 209 +++ internal/module/auth/logic/login.go | 151 +++ internal/module/auth/logic/login_test.go | 92 ++ internal/module/auth/logic/refresh.go | 135 ++ internal/module/auth/logic/static.go | 47 + internal/module/auth/service.go | 12 - internal/module/auth/static.go | 21 - .../conversation/entity/conversation.go | 15 + internal/module/conversation/entity/member.go | 10 + internal/module/conversation/entity/p2p.go | 15 + .../module/conversation/logic/conversation.go | 17 + internal/module/conversation/logic/p2p.go | 72 + .../module/conversation/model/conversation.go | 15 + .../conversation/model/conversation_member.go | 47 + internal/module/conversation/model/memory.go | 102 ++ .../conversation/model/p2p_conversation.go | 10 + internal/module/conversation/model/repo.go | 138 ++ internal/module/conversation/repository.go | 22 - internal/module/deliver/entity/job.go | 30 + internal/module/deliver/logic/done.go | 8 + internal/module/deliver/logic/queue.go | 15 + internal/module/deliver/logic/worker.go | 161 +++ internal/module/deliver/logic/worker_test.go | 110 ++ internal/module/deliver/model/memory.go | 117 ++ internal/module/deliver/model/redis_queue.go | 122 ++ internal/module/deliver/queue.go | 26 - internal/module/deliver/redis_queue.go | 78 -- internal/module/favorite/entity/favorite.go | 28 + internal/module/favorite/logic/copy.go | 51 + internal/module/favorite/logic/create.go | 117 ++ .../module/favorite/logic/favorite_test.go | 136 ++ internal/module/favorite/logic/list.go | 57 + internal/module/favorite/logic/service.go | 52 + internal/module/favorite/model/favorite.go | 44 + internal/module/favorite/model/memory.go | 104 ++ internal/module/favorite/model/repo.go | 114 ++ internal/module/friend/entity/accept.go | 8 + internal/module/friend/entity/block.go | 10 + internal/module/friend/entity/pair.go | 21 + internal/module/friend/entity/relation.go | 60 + internal/module/friend/entity/remark.go | 11 + internal/module/friend/entity/report.go | 26 + internal/module/friend/entity/request.go | 37 + internal/module/friend/entity/system.go | 4 + internal/module/friend/logic/accept.go | 73 ++ internal/module/friend/logic/apply.go | 183 +++ internal/module/friend/logic/block.go | 54 + internal/module/friend/logic/cansend.go | 31 + internal/module/friend/logic/friend_test.go | 220 ++++ internal/module/friend/logic/friends.go | 22 + internal/module/friend/logic/greeting.go | 94 ++ internal/module/friend/logic/lookup.go | 64 + internal/module/friend/logic/profile.go | 104 ++ internal/module/friend/logic/remark.go | 43 + internal/module/friend/logic/report.go | 41 + internal/module/friend/logic/requests.go | 47 + internal/module/friend/logic/service.go | 74 ++ internal/module/friend/model/accept.go | 55 + .../module/friend/model/friend_relation.go | 56 + internal/module/friend/model/friend_remark.go | 89 ++ internal/module/friend/model/friend_report.go | 36 + .../module/friend/model/friend_request.go | 132 ++ internal/module/friend/model/memory.go | 153 +++ internal/module/friend/model/memory_write.go | 151 +++ internal/module/friend/model/repo.go | 41 + internal/module/friend/model/user_block.go | 77 ++ internal/module/friend/service.go | 7 - internal/module/group/entity/collage.go | 105 ++ internal/module/group/entity/collage_test.go | 67 + internal/module/group/entity/group.go | 52 + internal/module/group/entity/member.go | 51 + internal/module/group/entity/system.go | 10 + internal/module/group/logic/avatar.go | 137 ++ internal/module/group/logic/create.go | 83 ++ internal/module/group/logic/get.go | 41 + internal/module/group/logic/group_test.go | 269 ++++ internal/module/group/logic/invite.go | 81 ++ internal/module/group/logic/lookup.go | 74 ++ internal/module/group/logic/member_update.go | 81 ++ internal/module/group/logic/members.go | 73 ++ internal/module/group/logic/notice.go | 65 + internal/module/group/logic/remove.go | 59 + internal/module/group/logic/service.go | 105 ++ internal/module/group/logic/transfer.go | 66 + internal/module/group/logic/update.go | 60 + internal/module/group/logic/view.go | 75 ++ internal/module/group/model/group.go | 167 +++ internal/module/group/model/group_member.go | 121 ++ internal/module/group/model/memory.go | 190 +++ internal/module/group/model/memory_write.go | 149 +++ internal/module/group/model/repo.go | 41 + internal/module/group/model/write.go | 210 +++ internal/module/group/service.go | 8 - internal/module/inbox/entity/preview.go | 8 + internal/module/inbox/{ => logic}/store.go | 4 +- internal/module/message/entity/content.go | 298 +++++ .../module/message/entity/content_test.go | 99 ++ internal/module/message/entity/message.go | 29 + internal/module/message/entity/payload.go | 71 + internal/module/message/entity/type.go | 30 + internal/module/message/logic/content.go | 19 + .../message/{service.go => logic/send.go} | 29 +- internal/module/message/model/memory.go | 64 + internal/module/message/model/message.go | 53 + internal/module/message/model/repo.go | 75 ++ internal/module/session/{ => logic}/policy.go | 3 +- .../sync/{service.go => entity/request.go} | 10 +- internal/module/sync/logic/service.go | 13 + .../module/sync/model/device_sync_cursor.go | 14 + internal/module/sync/model/mailbox_event.go | 18 + internal/module/upload/entity/credential.go | 17 + internal/module/upload/entity/purpose.go | 146 +++ internal/module/upload/entity/purpose_test.go | 55 + internal/module/upload/logic/credential.go | 90 ++ .../module/upload/logic/credential_test.go | 97 ++ internal/module/upload/logic/download.go | 48 + internal/module/upload/logic/service.go | 72 + internal/module/upload/service.go | 12 - internal/module/user/entity/avatar.go | 168 +++ internal/module/user/entity/avatar_font.go | 43 + internal/module/user/entity/avatar_test.go | 68 + internal/module/user/entity/device.go | 68 + internal/module/user/entity/email.go | 24 + internal/module/user/entity/email_test.go | 18 + internal/module/user/entity/login_log.go | 16 + internal/module/user/entity/qrcode.go | 31 + internal/module/user/entity/qrcode_test.go | 23 + internal/module/user/entity/spark_id.go | 48 + internal/module/user/entity/spark_id_test.go | 14 + internal/module/user/entity/user.go | 32 + internal/module/user/logic/avatar.go | 61 + internal/module/user/logic/email_code.go | 67 + internal/module/user/logic/email_code_test.go | 21 + internal/module/user/logic/find.go | 31 + internal/module/user/logic/login.go | 191 +++ internal/module/user/logic/login_test.go | 198 +++ internal/module/user/logic/profile.go | 181 +++ internal/module/user/logic/profile_test.go | 74 ++ internal/module/user/logic/service.go | 47 + internal/module/user/logic/service_test.go | 36 + internal/module/user/model/email_code.go | 68 + .../module/user/model/email_code_memory.go | 57 + internal/module/user/model/memory.go | 219 ++++ internal/module/user/model/repo.go | 42 + internal/module/user/model/user.go | 191 +++ internal/module/user/model/user_device.go | 81 ++ internal/module/user/model/user_email.go | 49 + internal/module/user/model/user_login_log.go | 48 + internal/module/user/service.go | 12 - internal/pkg/errs/apperror.go | 108 ++ internal/pkg/errs/apperror_test.go | 39 + internal/pkg/errs/codes.go | 28 + internal/pkg/errs/errs.go | 22 +- internal/pkg/i18n/catalog.go | 9 + internal/pkg/i18n/catalog_err.go | 66 + internal/pkg/i18n/catalog_notify.go | 42 + internal/pkg/i18n/catalog_validate.go | 96 ++ internal/pkg/i18n/catalog_validate_friend.go | 66 + internal/pkg/i18n/catalog_validate_group.go | 60 + internal/pkg/i18n/catalog_validate_upload.go | 60 + internal/pkg/i18n/catalog_validate_user.go | 45 + internal/pkg/i18n/i18n_test.go | 41 + internal/pkg/i18n/keys.go | 119 ++ internal/pkg/i18n/locale.go | 50 + internal/pkg/i18n/text.go | 62 + internal/pkg/ratelimit/ratelimit.go | 69 + internal/pkg/ratelimit/ratelimit_test.go | 19 + internal/pkg/reply/reply.go | 96 ++ internal/pkg/reply/reply_test.go | 46 + internal/pkg/reqctx/reqctx.go | 70 +- internal/pkg/reqctx/reqctx_test.go | 38 + internal/pkg/validate/validate.go | 110 ++ internal/pkg/validate/validate_test.go | 50 + internal/transport/http/auth.go | 115 ++ internal/transport/http/docs.go | 19 + internal/transport/http/docs/index.html | 26 + internal/transport/http/docs/openapi.yaml | 1154 +++++++++++++++++ internal/transport/http/embed.go | 11 + internal/transport/http/file_test.go | 115 ++ internal/transport/http/friend_test.go | 136 ++ internal/transport/http/group_test.go | 85 ++ .../transport/http/handler/auth/handler.go | 67 + .../http/handler/favorite/handler.go | 79 ++ .../transport/http/handler/file/handler.go | 64 + .../transport/http/handler/friend/handler.go | 131 ++ .../transport/http/handler/friend/member.go | 121 ++ .../transport/http/handler/group/handler.go | 128 ++ .../transport/http/handler/group/member.go | 134 ++ .../transport/http/handler/user/handler.go | 52 + internal/transport/http/middleware.go | 150 +++ internal/transport/http/respond/respond.go | 39 + internal/transport/http/router.go | 146 +++ internal/transport/http/server.go | 96 +- internal/transport/http/server_test.go | 434 +++++++ internal/transport/tcp/dispatch.go | 67 +- internal/transport/tcp/errors.go | 26 - internal/transport/tcp/handlers.go | 11 +- internal/transport/tcp/protocol/codes.go | 20 +- internal/transport/tcp/protocol/envelope.go | 22 + internal/transport/tcp/protocol/payload.go | 141 +- .../transport/tcp/protocol/payload_test.go | 20 + internal/transport/tcp/server.go | 71 +- internal/transport/tcp/server_test.go | 73 +- internal/transport/tcp/session/conn.go | 17 + internal/transport/tcp/session/conn_test.go | 16 + internal/transport/tcp/session/kick.go | 26 +- internal/transport/tcp/session/manager.go | 2 + internal/transport/tcp/taskpool.go | 31 +- internal/transport/tcp/taskpool_test.go | 29 + migrations/0001_init.sql | 80 +- migrations/0002_users.sql | 41 + migrations/0003_table_comments.sql | 82 ++ .../0004_user_devices_autoincrement.sql | 5 + migrations/0005_user_profile.sql | 9 + migrations/0006_user_login_logs.sql | 18 + migrations/0007_friends.sql | 50 + migrations/0008_favorites.sql | 13 + migrations/0009_groups.sql | 29 + 283 files changed, 19530 insertions(+), 617 deletions(-) create mode 100644 .cursor/rules/http-mvc.mdc create mode 100644 .cursor/rules/module-layers.mdc create mode 100644 .cursor/skills/spark-im-v3/http.md create mode 100644 .cursor/skills/spark-im-v3/i18n.md create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 cmd/migrate/main.go create mode 100644 deploy/docker/config.docker.yaml create mode 100755 deploy/docker/deploy.sh create mode 100644 deploy/docker/docker-compose.yaml create mode 100644 deploy/nginx/README.md create mode 100644 deploy/nginx/host/spark-acme.conf create mode 100644 deploy/nginx/host/spark-sni.conf create mode 100644 deploy/nginx/host/sparkapi.conf create mode 100755 deploy/nginx/install-host.sh create mode 100644 deploy/nginx/nginx.conf create mode 100644 examples/android/.gitignore create mode 100644 examples/android/app/build.gradle.kts create mode 100644 examples/android/app/src/main/AndroidManifest.xml create mode 100644 examples/android/app/src/main/java/com/spark/check/CheckActivity.kt create mode 100644 examples/android/app/src/main/java/com/spark/check/SparkClient.kt create mode 100644 examples/android/app/src/main/res/drawable/ic_launcher.xml create mode 100644 examples/android/app/src/main/res/xml/network_security_config.xml create mode 100644 examples/android/build.gradle.kts create mode 100644 examples/android/gradle.properties create mode 100644 examples/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 examples/android/gradle/wrapper/gradle-wrapper.properties create mode 100755 examples/android/gradlew create mode 100644 examples/android/gradlew.bat create mode 100644 examples/android/settings.gradle.kts create mode 100644 internal/bootstrap/health.go create mode 100644 internal/bootstrap/health_test.go create mode 100644 internal/infrastructure/logger/body.go create mode 100644 internal/infrastructure/logger/logger_test.go create mode 100644 internal/infrastructure/logger/options.go create mode 100644 internal/infrastructure/mysql/gorm_logger.go create mode 100644 internal/infrastructure/mysql/gorm_logger_test.go create mode 100644 internal/infrastructure/objectstore/ops.go create mode 100644 internal/infrastructure/objectstore/policy.go create mode 100644 internal/infrastructure/objectstore/store.go create mode 100644 internal/infrastructure/objectstore/store_test.go create mode 100644 internal/infrastructure/redis/redis_test.go create mode 100644 internal/module/auth/entity/principal.go create mode 100644 internal/module/auth/logic/denylist.go create mode 100644 internal/module/auth/logic/jwt.go create mode 100644 internal/module/auth/logic/jwt_test.go create mode 100644 internal/module/auth/logic/login.go create mode 100644 internal/module/auth/logic/login_test.go create mode 100644 internal/module/auth/logic/refresh.go create mode 100644 internal/module/auth/logic/static.go delete mode 100644 internal/module/auth/service.go delete mode 100644 internal/module/auth/static.go create mode 100644 internal/module/conversation/entity/conversation.go create mode 100644 internal/module/conversation/entity/member.go create mode 100644 internal/module/conversation/entity/p2p.go create mode 100644 internal/module/conversation/logic/conversation.go create mode 100644 internal/module/conversation/logic/p2p.go create mode 100644 internal/module/conversation/model/conversation.go create mode 100644 internal/module/conversation/model/conversation_member.go create mode 100644 internal/module/conversation/model/memory.go create mode 100644 internal/module/conversation/model/p2p_conversation.go create mode 100644 internal/module/conversation/model/repo.go delete mode 100644 internal/module/conversation/repository.go create mode 100644 internal/module/deliver/entity/job.go create mode 100644 internal/module/deliver/logic/done.go create mode 100644 internal/module/deliver/logic/queue.go create mode 100644 internal/module/deliver/logic/worker.go create mode 100644 internal/module/deliver/logic/worker_test.go create mode 100644 internal/module/deliver/model/memory.go create mode 100644 internal/module/deliver/model/redis_queue.go delete mode 100644 internal/module/deliver/queue.go delete mode 100644 internal/module/deliver/redis_queue.go create mode 100644 internal/module/favorite/entity/favorite.go create mode 100644 internal/module/favorite/logic/copy.go create mode 100644 internal/module/favorite/logic/create.go create mode 100644 internal/module/favorite/logic/favorite_test.go create mode 100644 internal/module/favorite/logic/list.go create mode 100644 internal/module/favorite/logic/service.go create mode 100644 internal/module/favorite/model/favorite.go create mode 100644 internal/module/favorite/model/memory.go create mode 100644 internal/module/favorite/model/repo.go create mode 100644 internal/module/friend/entity/accept.go create mode 100644 internal/module/friend/entity/block.go create mode 100644 internal/module/friend/entity/pair.go create mode 100644 internal/module/friend/entity/relation.go create mode 100644 internal/module/friend/entity/remark.go create mode 100644 internal/module/friend/entity/report.go create mode 100644 internal/module/friend/entity/request.go create mode 100644 internal/module/friend/entity/system.go create mode 100644 internal/module/friend/logic/accept.go create mode 100644 internal/module/friend/logic/apply.go create mode 100644 internal/module/friend/logic/block.go create mode 100644 internal/module/friend/logic/cansend.go create mode 100644 internal/module/friend/logic/friend_test.go create mode 100644 internal/module/friend/logic/friends.go create mode 100644 internal/module/friend/logic/greeting.go create mode 100644 internal/module/friend/logic/lookup.go create mode 100644 internal/module/friend/logic/profile.go create mode 100644 internal/module/friend/logic/remark.go create mode 100644 internal/module/friend/logic/report.go create mode 100644 internal/module/friend/logic/requests.go create mode 100644 internal/module/friend/logic/service.go create mode 100644 internal/module/friend/model/accept.go create mode 100644 internal/module/friend/model/friend_relation.go create mode 100644 internal/module/friend/model/friend_remark.go create mode 100644 internal/module/friend/model/friend_report.go create mode 100644 internal/module/friend/model/friend_request.go create mode 100644 internal/module/friend/model/memory.go create mode 100644 internal/module/friend/model/memory_write.go create mode 100644 internal/module/friend/model/repo.go create mode 100644 internal/module/friend/model/user_block.go delete mode 100644 internal/module/friend/service.go create mode 100644 internal/module/group/entity/collage.go create mode 100644 internal/module/group/entity/collage_test.go create mode 100644 internal/module/group/entity/group.go create mode 100644 internal/module/group/entity/member.go create mode 100644 internal/module/group/entity/system.go create mode 100644 internal/module/group/logic/avatar.go create mode 100644 internal/module/group/logic/create.go create mode 100644 internal/module/group/logic/get.go create mode 100644 internal/module/group/logic/group_test.go create mode 100644 internal/module/group/logic/invite.go create mode 100644 internal/module/group/logic/lookup.go create mode 100644 internal/module/group/logic/member_update.go create mode 100644 internal/module/group/logic/members.go create mode 100644 internal/module/group/logic/notice.go create mode 100644 internal/module/group/logic/remove.go create mode 100644 internal/module/group/logic/service.go create mode 100644 internal/module/group/logic/transfer.go create mode 100644 internal/module/group/logic/update.go create mode 100644 internal/module/group/logic/view.go create mode 100644 internal/module/group/model/group.go create mode 100644 internal/module/group/model/group_member.go create mode 100644 internal/module/group/model/memory.go create mode 100644 internal/module/group/model/memory_write.go create mode 100644 internal/module/group/model/repo.go create mode 100644 internal/module/group/model/write.go delete mode 100644 internal/module/group/service.go create mode 100644 internal/module/inbox/entity/preview.go rename internal/module/inbox/{ => logic}/store.go (80%) create mode 100644 internal/module/message/entity/content.go create mode 100644 internal/module/message/entity/content_test.go create mode 100644 internal/module/message/entity/message.go create mode 100644 internal/module/message/entity/payload.go create mode 100644 internal/module/message/entity/type.go create mode 100644 internal/module/message/logic/content.go rename internal/module/message/{service.go => logic/send.go} (56%) create mode 100644 internal/module/message/model/memory.go create mode 100644 internal/module/message/model/message.go create mode 100644 internal/module/message/model/repo.go rename internal/module/session/{ => logic}/policy.go (59%) rename internal/module/sync/{service.go => entity/request.go} (53%) create mode 100644 internal/module/sync/logic/service.go create mode 100644 internal/module/sync/model/device_sync_cursor.go create mode 100644 internal/module/sync/model/mailbox_event.go create mode 100644 internal/module/upload/entity/credential.go create mode 100644 internal/module/upload/entity/purpose.go create mode 100644 internal/module/upload/entity/purpose_test.go create mode 100644 internal/module/upload/logic/credential.go create mode 100644 internal/module/upload/logic/credential_test.go create mode 100644 internal/module/upload/logic/download.go create mode 100644 internal/module/upload/logic/service.go delete mode 100644 internal/module/upload/service.go create mode 100644 internal/module/user/entity/avatar.go create mode 100644 internal/module/user/entity/avatar_font.go create mode 100644 internal/module/user/entity/avatar_test.go create mode 100644 internal/module/user/entity/device.go create mode 100644 internal/module/user/entity/email.go create mode 100644 internal/module/user/entity/email_test.go create mode 100644 internal/module/user/entity/login_log.go create mode 100644 internal/module/user/entity/qrcode.go create mode 100644 internal/module/user/entity/qrcode_test.go create mode 100644 internal/module/user/entity/spark_id.go create mode 100644 internal/module/user/entity/spark_id_test.go create mode 100644 internal/module/user/entity/user.go create mode 100644 internal/module/user/logic/avatar.go create mode 100644 internal/module/user/logic/email_code.go create mode 100644 internal/module/user/logic/email_code_test.go create mode 100644 internal/module/user/logic/find.go create mode 100644 internal/module/user/logic/login.go create mode 100644 internal/module/user/logic/login_test.go create mode 100644 internal/module/user/logic/profile.go create mode 100644 internal/module/user/logic/profile_test.go create mode 100644 internal/module/user/logic/service.go create mode 100644 internal/module/user/logic/service_test.go create mode 100644 internal/module/user/model/email_code.go create mode 100644 internal/module/user/model/email_code_memory.go create mode 100644 internal/module/user/model/memory.go create mode 100644 internal/module/user/model/repo.go create mode 100644 internal/module/user/model/user.go create mode 100644 internal/module/user/model/user_device.go create mode 100644 internal/module/user/model/user_email.go create mode 100644 internal/module/user/model/user_login_log.go delete mode 100644 internal/module/user/service.go create mode 100644 internal/pkg/errs/apperror.go create mode 100644 internal/pkg/errs/apperror_test.go create mode 100644 internal/pkg/errs/codes.go create mode 100644 internal/pkg/i18n/catalog.go create mode 100644 internal/pkg/i18n/catalog_err.go create mode 100644 internal/pkg/i18n/catalog_notify.go create mode 100644 internal/pkg/i18n/catalog_validate.go create mode 100644 internal/pkg/i18n/catalog_validate_friend.go create mode 100644 internal/pkg/i18n/catalog_validate_group.go create mode 100644 internal/pkg/i18n/catalog_validate_upload.go create mode 100644 internal/pkg/i18n/catalog_validate_user.go create mode 100644 internal/pkg/i18n/i18n_test.go create mode 100644 internal/pkg/i18n/keys.go create mode 100644 internal/pkg/i18n/locale.go create mode 100644 internal/pkg/i18n/text.go create mode 100644 internal/pkg/ratelimit/ratelimit.go create mode 100644 internal/pkg/ratelimit/ratelimit_test.go create mode 100644 internal/pkg/reply/reply.go create mode 100644 internal/pkg/reply/reply_test.go create mode 100644 internal/pkg/reqctx/reqctx_test.go create mode 100644 internal/pkg/validate/validate.go create mode 100644 internal/pkg/validate/validate_test.go create mode 100644 internal/transport/http/auth.go create mode 100644 internal/transport/http/docs.go create mode 100644 internal/transport/http/docs/index.html create mode 100644 internal/transport/http/docs/openapi.yaml create mode 100644 internal/transport/http/embed.go create mode 100644 internal/transport/http/file_test.go create mode 100644 internal/transport/http/friend_test.go create mode 100644 internal/transport/http/group_test.go create mode 100644 internal/transport/http/handler/auth/handler.go create mode 100644 internal/transport/http/handler/favorite/handler.go create mode 100644 internal/transport/http/handler/file/handler.go create mode 100644 internal/transport/http/handler/friend/handler.go create mode 100644 internal/transport/http/handler/friend/member.go create mode 100644 internal/transport/http/handler/group/handler.go create mode 100644 internal/transport/http/handler/group/member.go create mode 100644 internal/transport/http/handler/user/handler.go create mode 100644 internal/transport/http/middleware.go create mode 100644 internal/transport/http/respond/respond.go create mode 100644 internal/transport/http/router.go create mode 100644 internal/transport/http/server_test.go create mode 100644 internal/transport/tcp/protocol/envelope.go create mode 100644 internal/transport/tcp/protocol/payload_test.go create mode 100644 migrations/0002_users.sql create mode 100644 migrations/0003_table_comments.sql create mode 100644 migrations/0004_user_devices_autoincrement.sql create mode 100644 migrations/0005_user_profile.sql create mode 100644 migrations/0006_user_login_logs.sql create mode 100644 migrations/0007_friends.sql create mode 100644 migrations/0008_favorites.sql create mode 100644 migrations/0009_groups.sql diff --git a/.cursor/rules/http-mvc.mdc b/.cursor/rules/http-mvc.mdc new file mode 100644 index 0000000..9d83ec8 --- /dev/null +++ b/.cursor/rules/http-mvc.mdc @@ -0,0 +1,16 @@ +--- +description: HTTP MVC, validator i18n keys, RealIP and language on context, REST groups. +globs: internal/transport/http/**/*.go,internal/pkg/validate/**/*.go,internal/pkg/reqctx/**/*.go,internal/pkg/i18n/**/*.go +alwaysApply: false +--- + +# HTTP MVC + +Source: `.cursor/skills/spark-im-v3/http.md` and `i18n.md`. + +- Handlers only bind and call Application services. Login and other business live in `internal/module/*`, never in Gin handlers. +- Bind JSON with `respond.BindJSON` + `validate` / `label` / `errmsg` **i18n keys**. Client `msg` is localized (`zh-CN` / `en` / `zh-TW`). +- Put `RealIP` and `Language` on `reqctx.RequestContext`. HTTP: `X-Language` or `Accept-Language`. TCP: AUTH `language`. Rate limit uses `reqctx.RealIP(ctx)`. Errors/notices use `reqctx.Lang(ctx)`. +- Routes are REST under `/v1/{resource}` and grouped by business in `router.go`. +- Do not import package `httpx` from `handler/*` (cycle). Use `internal/transport/http/respond`. +- Do not hardcode error or notification strings; add catalog entries in `internal/pkg/i18n`. diff --git a/.cursor/rules/message-deliver.mdc b/.cursor/rules/message-deliver.mdc index a791db3..3c6ea7c 100644 --- a/.cursor/rules/message-deliver.mdc +++ b/.cursor/rules/message-deliver.mdc @@ -17,3 +17,5 @@ Queue: `RPUSH` / `BRPOPLPUSH` to `deliver:processing` / `LREM` ack. On boot `Req Inbox: Redis ZSET `im:dialog:unread|read:{uid}`, preview, unread hash. Mailbox MySQL is only missed push + control events (recall/kick/friend). Never write `NEW_MESSAGE` mailbox for a successful online push. P2P conversation key is `(user_small, user_big)`. Sync messages with `id > last_msg_id`. + +`messages.content` is a BLOB of serialized JSON bytes for a typed payload (text/image/voice/video/emoji/gif/file/location/system). Do not store a raw string. Media bytes stay in object storage; content holds the object key plus metadata. diff --git a/.cursor/rules/module-layers.mdc b/.cursor/rules/module-layers.mdc new file mode 100644 index 0000000..4a8487d --- /dev/null +++ b/.cursor/rules/module-layers.mdc @@ -0,0 +1,19 @@ +--- +description: Module layering — entity, model, logic. One GORM file per table. +globs: internal/module/**/*.go +alwaysApply: false +--- + +# Module layers + +Each business module under `internal/module/{biz}` is three packages: + +| Layer | Path | Owns | +|-------|------|------| +| entity | `{biz}/entity` | Domain structs, message payload types, no I/O | +| model | `{biz}/model` | One Go file per MySQL table (GORM + COMMENT tags), Redis adapters | +| logic | `{biz}/logic` | One file per **function** (profile, login, send, inbox sync). Not one file per table. | + +`logic` must not import `model`. Wire implementations in `internal/bootstrap`. Auth has no tables (entity + logic only). + +Message `content` is serialized JSON **bytes** in a BLOB. Never persist a raw string. Media is an object-storage key, never file bytes. diff --git a/.cursor/skills/spark-im-v3/SKILL.md b/.cursor/skills/spark-im-v3/SKILL.md index e6e4003..0ba9a04 100644 --- a/.cursor/skills/spark-im-v3/SKILL.md +++ b/.cursor/skills/spark-im-v3/SKILL.md @@ -2,9 +2,11 @@ name: spark-im-v3 description: >- Implements Spark IM modular monolith per architecture V3. Use when writing or - reviewing spark-server Go code, TCP/gnet, Gin HTTP, messages, deliver queue, - inbox ZSET, Nginx 443 SNI, PROXY protocol, or SEND_ACK. Trigger terms: IM, - MsgId, TaskPool, deliver:queue, SYNC_INBOX, gnet, RealIP. + reviewing spark-server Go code, TCP/gnet, Gin HTTP, REST /v1 groups, MVC + handlers, go-playground validator, RealIP, messages, deliver queue, inbox + ZSET, Nginx 443 SNI, PROXY protocol, or SEND_ACK. Trigger terms: IM, MsgId, + TaskPool, deliver:queue, SYNC_INBOX, gnet, RealIP, validator, LoginService, + i18n, Accept-Language, zh-CN, uid, spark_id, infrastructure, entity, model, logic. --- # Spark IM V3 @@ -14,15 +16,55 @@ Read [invariants.md](invariants.md) before changing send/sync/transport. Full de ## Layout ```text -cmd/server process entry -internal/bootstrap config, lifecycle -internal/transport/http Gin, loopback :8080 -internal/transport/tcp gnet EventLoop + TaskPool + Frame -internal/module/* Application services (HTTP and TCP share these) -internal/infrastructure mysql, redis, id, logger, push +cmd/server process entry +cmd/migrate apply migrations/*.sql (re-runs all files) +internal/bootstrap config, lifecycle, wire model → logic +internal/transport/http Gin engine, REST groups, middleware +internal/transport/http/handler thin MVC controllers (no business) +internal/transport/http/respond BindJSON + envelope write +internal/transport/tcp gnet EventLoop + TaskPool + Frame +internal/module/{biz} entity / model / logic (see Layers) +internal/pkg process-agnostic helpers (no DSN / pools) +internal/infrastructure process adapters (mysql, redis, id, logger, push) ``` -Module files: `service.go` + entity types + `repository.go` interface. No extra DDD layer. +HTTP: read [http.md](http.md) — validator keys, `reqctx.RealIP`, `/v1/{resource}` groups, MVC login. +i18n: read [i18n.md](i18n.md) — request language, error `msg`, notification types. + +## Layers + +Each `internal/module/{biz}` is three packages. Do not put entity, GORM rows, and use-cases in one directory. + +| Layer | Path | Owns | +|-------|------|------| +| entity | `{biz}/entity` | Domain types, message payloads. No I/O. | +| model | `{biz}/model` | One Go file per MySQL table (GORM + COMMENT). Redis adapters. | +| logic | `{biz}/logic` | One file per **function** (profile, login, send). Not per table. | + +`logic` must not import `model`. Wire implementations in `internal/bootstrap`. Auth has no tables (entity + logic only). HTTP/TCP both call `logic`; Gin handlers only bind and write the envelope. + +## pkg vs infrastructure + +Do **not** move `infrastructure` into `pkg` (or a public repo-root `pkg/`). + +| Path | Owns | May import | +|------|------|------------| +| `internal/pkg` | `errs`, `i18n`, `validate`, `reqctx`, `reply`, in-memory `ratelimit` | stdlib + other pkg only | +| `internal/infrastructure` | MySQL pool, Redis client, Snowflake `id`, zap `logger`, push, metrics | pkg + vendor drivers | + +`pkg` has no connection lifecycle and must not know DSN, Redis AUTH, or `snowflake_worker_id`. `infrastructure` is opened/closed by `bootstrap`. Direction: infrastructure → pkg, never the reverse. + +## IDs + +Not every PK is Snowflake. Do not AUTO_INCREMENT natural keys. + +| Kind | Examples | Rule | +|------|----------|------| +| Snowflake | `users.id` (uid), `messages.id` (MsgId), `conversations.id` | Time-ordered business IDs. One `id.Generator` per process; `snowflake_worker_id` unique per process. Clock rewind fails issuance for the whole process. | +| AUTO_INCREMENT | `user_devices.id`, `groups.id` (群号) | Internal row id, or sequential public 群号. Business chat cursor is still Snowflake `conversations.id`. | +| Natural key | `user_emails.email`, P2P `(user_small, user_big)`, members, sync cursors | Composite / natural PK. No extra surrogate id. | + +`device_id` is **client-generated**, stored on the device, written to JWT `did`. The server does not allocate it. `uid` in JSON **is** `users.id`; do not add a second uid column. ## Send path (must keep this order) @@ -48,16 +90,27 @@ If step 5 fails, do not ACK. Client retries the same `client_msg_id`. `AUTH HEARTBEAT GENERATE_MSG_ID SEND_MESSAGE SEND_ACK MESSAGE PUSH_ACK READ RECALL SYNC_INBOX SYNC_MESSAGES KICK` Media: HTTP upload credential, message content is object key only. Frame max 64KB. Compress body over 512B. +Message types: text / image / voice / video / emoji / gif / file / location / system. `messages.content` stores serialized JSON bytes (BLOB), never a raw string. + +TCP body is JSON envelope `{code,msg,data}`. AUTH request is `{"token","device_id","app_version","language"}` (not protobuf). +Public TLS is Nginx `:443` SNI: HTTP cert on loopback `:8443`, IM cert on loopback `:9001` then PROXY to gnet `:9000`. See `deploy/nginx/`. +AUTH/login rate-limit uses `reqctx.RealIP`. TaskPool workers recover panics. Deliver workers start with `RequeueStale` + `SETNX deliver:done:{msgId}`. ## When adding a feature - HTTP writes must enqueue deliver jobs for online devices. - List/unread go to Redis ZSET, not MySQL row-per-member. - New goroutine I/O does not belong in `OnTraffic`. +- HTTP: thin handler + module `logic`; REST group by business; validate with i18n `label`/`errmsg` keys; put RealIP and language on context. +- Login is email OTP (`POST /v1/auth/login`). `uid` is Snowflake `users.id`; public `spark_id` is unique and mutable. Email↔user lives in `user_emails`. Login upserts `user_devices` (client `device_id`). +- SQL tables and GORM models carry COMMENT on the table and every column. +- After adding or changing `migrations/*.sql`, run `go run ./cmd/migrate -config configs/config.yaml` in this session. Do not leave it for deploy. - Run `make lint` (funlen 100). ## Additional resources - [invariants.md](invariants.md) - [send-path.md](send-path.md) +- [http.md](http.md) +- [i18n.md](i18n.md) - `docs/architecture-v3.md` diff --git a/.cursor/skills/spark-im-v3/http.md b/.cursor/skills/spark-im-v3/http.md new file mode 100644 index 0000000..818fe0d --- /dev/null +++ b/.cursor/skills/spark-im-v3/http.md @@ -0,0 +1,105 @@ +# HTTP: validator, RealIP, REST groups, MVC + +HTTP and TCP share Application services in `internal/module/*`. Gin is a thin adapter. + +## MVC + +| Layer | Lives in | Owns | +|-------|----------|------| +| Model / application | `internal/module/{biz}/{entity,model,logic}` | Domain types; one GORM file per table; logic by function, not by table | +| Controller | `internal/transport/http/handler/{biz}` | Bind, call service, write envelope | +| View | `internal/pkg/reply` + `internal/transport/http/respond` | `{code,msg,data}` | + +Handlers must not contain business rules (credential check, token issue, friend/group permission, persist). Example: login belongs on `auth/logic.LoginService`, not in the Gin handler. + +```go +func (h *Handler) Login(c *gin.Context) { + var in auth.LoginInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + tok, err := h.login.Login(c.Request.Context(), &in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, tok) +} +``` + +Wire services in `internal/bootstrap`, pass them as `httpx.EngineDeps`. TCP commands call the same services. + +Do not import `internal/transport/http` (package `httpx`) from `handler/*` — that is an import cycle. Use `respond` for Bind/JSON. + +## Validator (`github.com/go-playground/validator/v10`) + +Use `validate` tags. Bind with `respond.BindJSON` (JSON decode + `validate.StructLang`). Do not use Gin's `binding` tags as the source of client messages. + +`label` and `errmsg` are **i18n keys** (see [i18n.md](i18n.md)), not a single language's sentences: + +- `label:"field.email"` — field name in default templates (`validate.required` → `{label}不能为空` / `{label} is required`) +- `errmsg:"required:validate.login.email_required"` — per-tag key, `;` separates tags + +Failed validation is `errs.ErrInvalidArgument` (`40001`) with `Msg` already localized. Transport must not replace it. + +Shared DTO example (`internal/module/auth/logic.LoginInput`): + +```go +type LoginInput struct { + Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required;email:validate.login.email_invalid"` + VerifyCode string `json:"verify_code" validate:"required" label:"field.verify_code" errmsg:"required:validate.login.code_required"` + DeviceID uint64 `json:"device_id" validate:"required" label:"field.device_id" errmsg:"required:validate.login.device_required"` +} +``` + +TCP may call `validate.StructLang` / `LoginService` on the same struct. Keep DTOs in the module, not in the handler package. + +## RealIP on context (C6) + +`reqctx.RequestContext.RealIP` is the client address for rate limit and audit. Never use gnet `RemoteAddr` (Nginx → 127.0.0.1). + +| Transport | How RealIP is set | +|-----------|-------------------| +| HTTP | `X-Real-IP`, else first `X-Forwarded-For`, else `RemoteAddr` host (`reqctx.ParseHTTPRealIP`) | +| TCP | PROXY protocol on the session, copied in `session.RequestContext()` | + +Read it with `reqctx.RealIP(ctx)`. Trace middleware must attach `*reqctx.RequestContext` before Auth and handlers. Zap fields already include `real_ip` via `logger.Attach`. + +Gin binds loopback only (C4); Nginx on 443 sets `X-Real-IP`. `SetTrustedProxies` is `127.0.0.1` / `::1`. + +## Language on context + +Every HTTP request carries `X-Language` or `Accept-Language`. TCP AUTH carries `language`. Canonical values: `zh-CN` (default), `en`, `zh-TW`. Details: [i18n.md](i18n.md). + +## REST grouping + +Public HTTP is `/v1/{resource}/...`. Register with Gin groups in `internal/transport/http/router.go`, one function per business. + +| Group | Prefix | Examples | +|-------|--------|----------| +| system | `/healthz`, `/readyz`, `/docs` | liveness, readiness, OpenAPI | +| auth | `/v1/auth` | `POST /v1/auth/login`, `POST /v1/auth/email-codes`, `POST /v1/auth/token/refresh`, `POST /v1/auth/logout` | +| users | `/v1/users` | `GET/PATCH /v1/users/me` | +| friends | `/v1/friends` | apply, requests, accept, profile, remark, block, report | +| groups | `/v1/groups` | create, profile, members, invite, kick, leave, transfer, dissolve | +| conversations | `/v1/conversations` | list, mute | +| messages | `/v1/messages` | history (sync still prefers TCP) | +| files | `/v1/files` | upload credential | + +Rules: + +- Noun resources, HTTP verbs for actions. No `/getUser`, `/sendMessage` under HTTP. +- Login stays `POST /v1/auth/login` (create session). Do not put login under `/v1/users`. +- Anonymous paths belong in `publicPath` (login, email-codes, token refresh, healthz, readyz, docs). +- OpenAPI `tags` must match these groups. + +## Checklist when adding an HTTP API + +1. Input struct in `internal/module/{biz}` with `validate` / `label` / `errmsg` **keys**. +2. Use-case method on a service in that module (HTTP and TCP share it). +3. Thin handler under `internal/transport/http/handler/{biz}`. +4. Register on the matching `v1.Group` in `router.go`. +5. Document the path and tag in `internal/transport/http/docs/openapi.yaml`. +6. Assert `reqctx.RealIP(ctx)` wherever you rate-limit or audit. +7. Localize errors/notifications via `internal/pkg/i18n`; never hardcode `msg`. diff --git a/.cursor/skills/spark-im-v3/i18n.md b/.cursor/skills/spark-im-v3/i18n.md new file mode 100644 index 0000000..dc6a169 --- /dev/null +++ b/.cursor/skills/spark-im-v3/i18n.md @@ -0,0 +1,57 @@ +# i18n (zh-CN / en / zh-TW) + +User-visible **error `msg`** and **notification copy** are never hardcoded in handlers. They come from `internal/pkg/i18n` using the request language. + +## Language on every request + +Canonical tags: `zh-CN` (default), `en`, `zh-TW`. Parse with `i18n.Parse`; store on `reqctx.RequestContext.Language`. Read with `reqctx.Lang(ctx)` / `reqctx.Language(ctx)`. + +| Transport | Client sends | Server stores | +|-----------|--------------|----------------| +| HTTP | `X-Language` if set, else `Accept-Language` | Trace middleware, every request | +| TCP | AUTH JSON field `language` | `session.Conn.SetLanguage` for the connection; copied into each command's `RequestContext` | + +Missing or unknown tags → `zh-CN`. `zh` / `zh-Hans` → `zh-CN`. `zh-TW` / `zh-HK` / `zh-Hant` → `zh-TW`. `en*` → `en`. + +Do not put language in JWT. Rate limit still uses `reqctx.RealIP`, not language. + +## Errors + +Sentinel `AppError` keeps an English `Msg` for logs. Envelope `msg` is localized at the transport boundary: + +- HTTP: `reply.FromContext(ctx, err)` +- TCP: `protocol.EncodeErrLang(i18n.Parse(conn.Language()), err)` + +`WithMsg` is only for **already-localized** text (validator). Do not `WithMsg("unauthorized")` in a handler. + +Add a new business code: register `err.{code}` in `internal/pkg/i18n/catalog_err.go` for all three languages. + +## Validation + +`label` and `errmsg` are **catalog keys**, not Chinese sentences: + +```go +Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required"` +``` + +`validate.StructLang(reqctx.Lang(ctx), in)`. Default templates are `validate.required` etc. with `{label}` / `{param}`. + +## Notification types + +Push and in-app system notices use type keys, then `i18n.Notify(lang, typ, args)` for title/body. Recipient language is the **target device** session language (TCP) or the user's stored lang — never the sender's. + +| Type | Key | +|------|-----| +| Same-device kick | `notify.kick.replaced` | +| Friend request | `notify.friend.request` | +| Group invite | `notify.group.invite` | +| Removed from group | `notify.group.removed` | +| Message recall | `notify.message.recall` | + +`push.Notification.Type` holds the key. Fill `Title`/`Body` with `i18n.Notify` before `Notifier.Notify`. Add new types in `catalog_notify.go` (`.title` / `.body`, `{name}` placeholders). + +## Do not + +- Hardcode `msg` or kick/push strings in `handler/*` or EventLoop. +- Localize with the sender language when notifying another user. +- Introduce a fourth locale without adding it to `i18n.Lang` and every catalog entry. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d73ac35 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.cursor +.idea +.vscode +bin +logs +vendor +examples +docs +*.md +*.test +*.out +coverage.out +.env +certs +deploy/nginx diff --git a/.gitignore b/.gitignore index 713b24b..cdf32ed 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ bin/ +logs/ vendor/ *.exe *.test diff --git a/.golangci.yaml b/.golangci.yaml index 47f9275..90ed6aa 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -5,7 +5,7 @@ run: tests: true go: "1.26" -linters-settings: +linters_settings: funlen: lines: 100 statements: 100 diff --git a/AGENTS.md b/AGENTS.md index a33e3b4..259c598 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ Before writing code: 1. Load `.cursor/skills/spark-im-v3/SKILL.md` 2. Follow `.cursor/rules/` (always-on V3 invariants + file-scoped rules) 3. Keep functions within `.golangci.yaml` (`funlen` 100, `lll` 240) -4. HTTP and TCP share Application services under `internal/module/*` +4. HTTP and TCP share Application services under `internal/module/{biz}/logic`. Each module is `entity` / `model` (one file per table) / `logic` (one file per function, not per table). Gin handlers only bind and call those services (MVC). REST paths are `/v1/{resource}` grouped by business. Request DTOs use go-playground/validator (`label` / `errmsg` i18n keys). Context must carry `reqctx.RealIP` and the user language (`zh-CN` / `en` / `zh-TW`). Error `msg` and notification copy come from `internal/pkg/i18n`. Table and column COMMENT is required. Message `content` is serialized bytes, not a string. +5. After adding or changing `migrations/*.sql`, run `go run ./cmd/migrate -config configs/config.yaml` yourself. Do not ask the user to apply it later. Do not introduce Kafka, K8s, microservices, ConversationSeq, application-layer handshake crypto, or public gnet ports. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..85d62f3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1 +FROM golang:1.26-bookworm AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/spark-server ./cmd/server \ + && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/spark-migrate ./cmd/migrate + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata wget \ + && adduser -D -H -u 10001 spark +ENV TZ=Asia/Shanghai +WORKDIR /app +COPY --from=build /out/spark-server /usr/local/bin/spark-server +COPY --from=build /out/spark-migrate /usr/local/bin/spark-migrate +COPY migrations /app/migrations +COPY deploy/docker/config.docker.yaml /app/configs/config.yaml +RUN mkdir -p /app/logs && chown -R spark:spark /app +USER spark +EXPOSE 8080 9000 +HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1 +ENTRYPOINT ["/usr/local/bin/spark-server"] +CMD ["-config", "/app/configs/config.yaml"] diff --git a/Makefile b/Makefile index a47d11d..b4bd077 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,9 @@ GO ?= go MODULE := spark-server BIN := bin/spark-server LINT ?= golangci-lint +CONFIG ?= configs/config.yaml -.PHONY: all build test lint fmt vet tidy run +.PHONY: all build test lint fmt vet tidy migrate start run deploy all: lint test build @@ -25,5 +26,14 @@ lint: tidy: $(GO) mod tidy +migrate: + $(GO) run ./cmd/migrate -config $(CONFIG) + +start: + $(GO) run ./cmd/server -config $(CONFIG) + run: build - ./$(BIN) -config configs/config.yaml + ./$(BIN) -config $(CONFIG) + +deploy: + bash deploy/docker/deploy.sh diff --git a/README.md b/README.md index dc655f8..b66f76b 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,19 @@ Spark IM modular monolith. Architecture: [docs/architecture-v3.md](docs/architecture-v3.md). - Go 1.26 -- Gin HTTP on `127.0.0.1:8080` -- gnet TCP on `127.0.0.1:9000` (EventLoop + TaskPool). gnet v2.10 has no built-in TLS; production terminates TLS at Nginx or a PROXY+TLS wrapper in front of gnet. Loopback is not public. +- Gin HTTP on `127.0.0.1:8080`. JSON envelope `{"code","msg","data"}`. Docs: `GET /docs`, spec: `GET /openapi.yaml`. +- Probes: `GET /healthz` liveness; `GET /readyz` readiness (HTTP 503 when MySQL/Redis/task pool/accepting are not ready). `env: prod` (or `SPARK_ENV=prod`) rejects empty MySQL DSN, empty Redis, and the default JWT secret. +- Logs: zap to console and/or rotating file (`log.console` / `log.file`). GORM SQL goes through the same zap. `trace_id` / `request_id` follow context (`X-Request-ID` / `X-Trace-ID`). +- gnet TCP on `127.0.0.1:9000` (EventLoop + TaskPool). gnet v2.10 has no built-in TLS; Nginx terminates IM TLS and forwards PROXY+plaintext. Sample: `deploy/nginx/`. +- TCP Frame body is JSON `{code,msg,data}`. AUTH body: `{"token","device_id","app_version","language"}`. - Public entry is Nginx `:443` only (SNI split) ```bash -go build -o bin/spark-server ./cmd/server -./bin/spark-server -config configs/config.yaml +make start +make migrate make lint ``` +`make start` runs `cmd/server` against `configs/config.yaml` (override with `make start CONFIG=path/to.yaml`). `make run` builds `bin/spark-server` first, then starts that binary. + Agent conventions live in `.cursor/rules` and `.cursor/skills/spark-im-v3`. Lint conventions live in `.golangci.yaml`. diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..95e0dd7 --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + mysqldriver "github.com/go-sql-driver/mysql" + "go.uber.org/zap" + + "spark-server/internal/bootstrap" + "spark-server/internal/infrastructure/mysql" + redisx "spark-server/internal/infrastructure/redis" +) + +func main() { + cfgPath := flag.String("config", "configs/config.yaml", "config path") + dir := flag.String("dir", "migrations", "SQL migrations directory") + flag.Parse() + if err := run(*cfgPath, *dir); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } +} + +func run(cfgPath, dir string) error { + cfg, err := bootstrap.Load(cfgPath) + if err != nil { + return err + } + log := zap.NewNop() + db, err := mysql.Open(log, &mysql.Options{ + DSN: cfg.MySQL.DSN, + SlowThreshold: cfg.MySQL.SlowThreshold, + MaxOpenConns: cfg.MySQL.MaxOpenConns, + MaxIdleConns: cfg.MySQL.MaxIdleConns, + ConnMaxLifetime: cfg.MySQL.ConnMaxLifetime, + ConnMaxIdleTime: cfg.MySQL.ConnMaxIdleTime, + }) + if err != nil { + return fmt.Errorf("mysql: %w", err) + } + defer db.Close() + if !db.Ready() { + return fmt.Errorf("mysql: dsn is empty") + } + rdb, err := redisx.Open(&redisx.Options{ + Addr: cfg.Redis.Addr, + Username: cfg.Redis.Username, + Password: cfg.Redis.Password, + DB: cfg.Redis.DB, + }) + if err != nil { + return fmt.Errorf("redis: %w", err) + } + defer rdb.Close() + if !rdb.Ready() { + return fmt.Errorf("redis: addr is empty") + } + pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer pingCancel() + if err := rdb.Ping(pingCtx); err != nil { + return fmt.Errorf("redis ping: %w", err) + } + files, err := filepath.Glob(filepath.Join(dir, "*.sql")) + if err != nil { + return err + } + gdb := db.Gorm() + ctx := context.Background() + for _, path := range files { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + for i, stmt := range splitSQL(string(raw)) { + if err := gdb.WithContext(ctx).Exec(stmt).Error; err != nil { + if ignorableDDL(err) { + continue + } + return fmt.Errorf("%s statement %d: %w", filepath.Base(path), i+1, err) + } + } + fmt.Printf("ok %s\n", filepath.Base(path)) + } + fmt.Println("mysql ok, redis ok") + return nil +} + +func splitSQL(raw string) []string { + var b strings.Builder + for _, line := range strings.Split(raw, "\n") { + trim := strings.TrimSpace(line) + if trim == "" || strings.HasPrefix(trim, "--") { + continue + } + b.WriteString(line) + b.WriteByte('\n') + } + parts := strings.Split(b.String(), ";") + out := make([]string, 0, len(parts)) + for _, p := range parts { + s := strings.TrimSpace(p) + if s != "" { + out = append(out, s) + } + } + return out +} + +func ignorableDDL(err error) bool { + var me *mysqldriver.MySQLError + if !errors.As(err, &me) { + return false + } + switch me.Number { + case 1050, // table already exists + 1060, // duplicate column + 1061: // duplicate key name + return true + default: + return false + } +} diff --git a/configs/config.yaml b/configs/config.yaml index 66e4730..5336420 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -1,8 +1,10 @@ +env: dev + server: http: addr: "127.0.0.1:8080" tcp: - addr: "127.0.0.1:9000" + addr: "127.0.0.1:8090" proxy_protocol: true tls: cert_file: "" @@ -18,13 +20,42 @@ server: id: snowflake_worker_id: 1 +log: + level: debug + encoding: console + console: true + file: + path: "logs/spark-server.log" + max_size_mb: 100 + max_backups: 7 + max_age_days: 7 + compress: true + mysql: dsn: "" + slow_threshold: 200ms + log_level: "" + max_open_conns: 64 + max_idle_conns: 16 + conn_max_lifetime: 30m + conn_max_idle_time: 10m redis: addr: "" + username: "" + password: "" + db: 0 deliver_queue_key: "deliver:queue" deliver_processing_key: "deliver:processing" + deliver_done_prefix: "deliver:done:" + +jwt: + secret: "dev-only-change-me" + issuer: "spark-im" + ttl: 168h + refresh_ttl: 720h + denylist_prefix: "denylist:" + refresh_prefix: "refresh:" taskpool: size: 256 @@ -44,8 +75,19 @@ rate_limit: sync_inbox_per_minute: 60 sync_messages_per_minute: 120 history_per_minute: 200 + upload_per_second: 10 message: max_content_size: 65536 sync_limit: 200 msgid_expire_ms: 300000 + +object_storage: + endpoint: "" + access_key: "" + secret_key: "" + bucket: "spark" + region: "us-east-1" + use_ssl: false + presign_ttl: 15m + chat_expire_days: 365 diff --git a/deploy/docker/config.docker.yaml b/deploy/docker/config.docker.yaml new file mode 100644 index 0000000..b4bf1a3 --- /dev/null +++ b/deploy/docker/config.docker.yaml @@ -0,0 +1,93 @@ +env: prod + +server: + http: + addr: "0.0.0.0:8080" + tcp: + addr: "0.0.0.0:9000" + proxy_protocol: true + tls: + cert_file: "" + key_file: "" + max_frame_size: 65536 + idle_timeout: 120s + keepalive: 30s + recv_buffer: 16777216 + send_buffer: 16777216 + multicore: true + compress_min_size: 512 + +id: + snowflake_worker_id: 1 + +log: + level: info + encoding: json + console: true + file: + path: "logs/spark-server.log" + max_size_mb: 100 + max_backups: 7 + max_age_days: 7 + compress: true + +mysql: + dsn: "spark:@tcp(mysql:3306)/spark?charset=utf8mb4&parseTime=True&loc=Local" + slow_threshold: 200ms + log_level: "" + max_open_conns: 64 + max_idle_conns: 16 + conn_max_lifetime: 30m + conn_max_idle_time: 10m + +redis: + addr: "redis:6379" + username: "" + password: "" + db: 0 + deliver_queue_key: "deliver:queue" + deliver_processing_key: "deliver:processing" + deliver_done_prefix: "deliver:done:" + +jwt: + secret: "dev-only-change-me" + issuer: "spark-im" + ttl: 168h + refresh_ttl: 720h + denylist_prefix: "denylist:" + refresh_prefix: "refresh:" + +taskpool: + size: 256 + queue: 1024 + +deliver: + workers: 8 + take_timeout: 5s + done_ttl: 10m + +session: + outbound_queue_size: 1024 + +rate_limit: + message_per_second: 20 + auth_per_second: 5 + sync_inbox_per_minute: 60 + sync_messages_per_minute: 120 + history_per_minute: 200 + upload_per_second: 10 + +message: + max_content_size: 65536 + sync_limit: 200 + msgid_expire_ms: 300000 + +object_storage: + endpoint: "rustfs:9000" + access_key: "" + secret_key: "" + bucket: "spark" + region: "us-east-1" + use_ssl: false + presign_ttl: 15m + chat_expire_days: 365 diff --git a/deploy/docker/deploy.sh b/deploy/docker/deploy.sh new file mode 100755 index 0000000..f6efda2 --- /dev/null +++ b/deploy/docker/deploy.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Deploy spark-server to the ECS host and join mysql/redis/rustfs on shell_default. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +HOST="${SPARK_SSH_HOST:-root@47.236.66.9}" +KEY="${SPARK_SSH_KEY:-$HOME/.ssh/spark-ecs}" +REMOTE="${SPARK_REMOTE_DIR:-/opt/spark-server}" +SSH=(ssh -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$HOST") + +rsync -az --delete \ + --exclude '.git/' \ + --exclude 'bin/' \ + --exclude 'logs/' \ + --exclude 'vendor/' \ + --exclude 'examples/' \ + --exclude '.cursor/' \ + --exclude '.idea/' \ + --exclude '.vscode/' \ + -e "ssh -i $KEY -o BatchMode=yes" \ + "$ROOT/" "$HOST:$REMOTE/" + +"${SSH[@]}" "mkdir -p /data/spark-server/logs && chown -R 10001:10001 /data/spark-server/logs" +"${SSH[@]}" "cd $REMOTE/deploy/docker && docker compose -f docker-compose.yaml build" +"${SSH[@]}" "cd $REMOTE/deploy/docker && docker compose -f docker-compose.yaml up -d spark-server" +"${SSH[@]}" "docker exec spark-server /usr/local/bin/spark-migrate -config /app/configs/config.yaml -dir /app/migrations" +"${SSH[@]}" "docker ps --filter name=spark-server --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'" +"${SSH[@]}" "curl -fsS http://127.0.0.1:18080/healthz && echo && curl -fsS http://127.0.0.1:18080/readyz && echo" diff --git a/deploy/docker/docker-compose.yaml b/deploy/docker/docker-compose.yaml new file mode 100644 index 0000000..a58be45 --- /dev/null +++ b/deploy/docker/docker-compose.yaml @@ -0,0 +1,24 @@ +# Joins the existing mysql/redis/rustfs network (docker compose project "shell"). +services: + spark-server: + build: + context: ../.. + dockerfile: Dockerfile + image: spark-server:latest + container_name: spark-server + restart: unless-stopped + environment: + TZ: Asia/Shanghai + ports: + - "127.0.0.1:18080:8080" + - "127.0.0.1:19000:9000" + volumes: + - /data/spark-server/logs:/app/logs + - ./config.docker.yaml:/app/configs/config.yaml:ro + networks: + - shell + +networks: + shell: + name: shell_default + external: true diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md new file mode 100644 index 0000000..e5658e3 --- /dev/null +++ b/deploy/nginx/README.md @@ -0,0 +1,30 @@ +# Nginx 443 SNI (C3 / C4) + +gnet v2 has no native TLS. IM certificates live here, not in spark-server `tcp.tls`. + +```text +Client --TLS--> :443 ssl_preread + ├─ SNI sparkapi.wyrs365.com → 127.0.0.1:8443 (HTTP TLS) + │ → proxy_pass http://127.0.0.1:18080 + X-Real-IP + └─ SNI sparkim.wyrs365.com → 127.0.0.1:19001 (IM TLS) + → PROXY protocol → 127.0.0.1:19000 gnet +``` + +On this ECS, `:443` is shared with other wyrs HTTPS sites. Do **not** replace `/etc/nginx/nginx.conf`. Drop-ins: + +- `host/sparkapi.conf` → `/etc/nginx/conf.d/` +- `host/spark-sni.conf` → `/etc/nginx/stream.d/` + +Existing `listen 443 ssl` vhosts move to `127.0.0.1:8443 ssl proxy_protocol` so SNI can take public 443 and `$remote_addr` stays the client IP. + +spark-server (Docker): + +- HTTP `127.0.0.1:18080` → container `:8080` +- TCP `127.0.0.1:19000` with `proxy_protocol: true` → container `:9000` +- Public security group: 443 only + +```bash +bash deploy/nginx/install-host.sh +``` + +`http.conf` / `stream.conf` / `nginx.conf` are a dedicated-box template. `/readyz` is loopback-only on 8443; local probes hit Gin on `127.0.0.1:18080`. diff --git a/deploy/nginx/host/spark-acme.conf b/deploy/nginx/host/spark-acme.conf new file mode 100644 index 0000000..c33e359 --- /dev/null +++ b/deploy/nginx/host/spark-acme.conf @@ -0,0 +1,15 @@ +# Temporary HTTP-01 vhosts. Replaced by sparkapi.conf after certificates exist. + +server { + listen 80; + listen [::]:80; + server_name sparkapi.wyrs365.com sparkim.wyrs365.com; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 404; + } +} diff --git a/deploy/nginx/host/spark-sni.conf b/deploy/nginx/host/spark-sni.conf new file mode 100644 index 0000000..feeff37 --- /dev/null +++ b/deploy/nginx/host/spark-sni.conf @@ -0,0 +1,29 @@ +# Spark IM + shared HTTPS on :443 (ssl_preread). +# sparkim.wyrs365.com → IM TLS terminator → PROXY plaintext to gnet :19000 +# default SNI → existing HTTP sites on 127.0.0.1:8443 (proxy_protocol restores RealIP) + +map $ssl_preread_server_name $spark_sni_backend { + sparkim.wyrs365.com 127.0.0.1:19001; + default 127.0.0.1:8443; +} + +server { + listen 443 reuseport; + listen [::]:443 reuseport; + ssl_preread on; + proxy_connect_timeout 5s; + proxy_timeout 3600s; + proxy_protocol on; + proxy_pass $spark_sni_backend; +} + +server { + listen 127.0.0.1:19001 ssl proxy_protocol; + ssl_certificate /etc/letsencrypt/live/sparkim.wyrs365.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/sparkim.wyrs365.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + proxy_connect_timeout 5s; + proxy_timeout 3600s; + proxy_protocol on; + proxy_pass 127.0.0.1:19000; +} diff --git a/deploy/nginx/host/sparkapi.conf b/deploy/nginx/host/sparkapi.conf new file mode 100644 index 0000000..ccc37f4 --- /dev/null +++ b/deploy/nginx/host/sparkapi.conf @@ -0,0 +1,77 @@ +# Spark HTTP — sparkapi.wyrs365.com +# Public :443 is stream ssl_preread (see stream.d/spark-sni.conf). +# This vhost terminates TLS on loopback :8443 (PROXY protocol from stream). + +upstream spark_http { + server 127.0.0.1:18080; + keepalive 16; +} + +server { + listen 80; + listen [::]:80; + server_name sparkapi.wyrs365.com; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 80; + listen [::]:80; + server_name sparkim.wyrs365.com; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 404; + } +} + +server { + listen 127.0.0.1:8443 ssl proxy_protocol; + listen [::1]:8443 ssl proxy_protocol; + http2 on; + server_name sparkapi.wyrs365.com; + + ssl_certificate /etc/letsencrypt/live/sparkapi.wyrs365.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/sparkapi.wyrs365.com/privkey.pem; + ssl_session_timeout 1d; + ssl_session_cache shared:SparkSSL:10m; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + + client_max_body_size 32m; + proxy_connect_timeout 5s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + proxy_http_version 1.1; + proxy_set_header Connection ""; + + location /readyz { + allow 127.0.0.1; + allow ::1; + deny all; + proxy_pass http://spark_http; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } + + location / { + proxy_pass http://spark_http; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } +} diff --git a/deploy/nginx/http.conf b/deploy/nginx/http.conf index 39a696f..1f70083 100644 --- a/deploy/nginx/http.conf +++ b/deploy/nginx/http.conf @@ -2,6 +2,20 @@ http { server { listen 127.0.0.1:8443 ssl; server_name api.example.com; + ssl_certificate /etc/nginx/certs/api.example.com.crt; + ssl_certificate_key /etc/nginx/certs/api.example.com.key; + ssl_protocols TLSv1.2 TLSv1.3; + + location /readyz { + allow 127.0.0.1; + allow ::1; + deny all; + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } location / { proxy_pass http://127.0.0.1:8080; diff --git a/deploy/nginx/install-host.sh b/deploy/nginx/install-host.sh new file mode 100755 index 0000000..e4eef41 --- /dev/null +++ b/deploy/nginx/install-host.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Install Spark SNI vhosts into the shared host nginx (do not replace nginx.conf). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +HOST_DIR="$ROOT/host" +BACKUP="/root/nginx-backup-$(date +%Y%m%d%H%M%S)" + +need_certs() { + [[ -f /etc/letsencrypt/live/sparkapi.wyrs365.com/fullchain.pem ]] \ + && [[ -f /etc/letsencrypt/live/sparkim.wyrs365.com/fullchain.pem ]] +} + +install_acme() { + mkdir -p /var/www/certbot + cp "$HOST_DIR/spark-acme.conf" /etc/nginx/conf.d/spark-acme.conf + nginx -t + systemctl reload nginx +} + +issue_certs() { + certbot certonly --webroot -w /var/www/certbot \ + --non-interactive --agree-tos --keep-until-expiring \ + -d sparkapi.wyrs365.com + certbot certonly --webroot -w /var/www/certbot \ + --non-interactive --agree-tos --keep-until-expiring \ + -d sparkim.wyrs365.com +} + +rewrite_http_443() { + local f + for f in /etc/nginx/conf.d/*.conf; do + case "$(basename "$f")" in + sparkapi.conf|spark-acme.conf) continue ;; + esac + if grep -qE 'listen[[:space:]]+127\.0\.0\.1:8443' "$f"; then + continue + fi + if grep -qE 'listen[[:space:]]+443[[:space:]]+ssl' "$f"; then + sed -i \ + -e 's/listen 443 ssl;/listen 127.0.0.1:8443 ssl proxy_protocol;/' \ + -e 's/listen \[::\]:443 ssl;/listen [::1]:8443 ssl proxy_protocol;/' \ + "$f" + fi + done +} + +install_sni() { + rewrite_http_443 + rm -f /etc/nginx/conf.d/spark-acme.conf + cp "$HOST_DIR/sparkapi.conf" /etc/nginx/conf.d/sparkapi.conf + cp "$HOST_DIR/spark-sni.conf" /etc/nginx/stream.d/spark-sni.conf + if grep -RInE 'listen[[:space:]]+(\[::\]:)?443[[:space:]]+ssl' /etc/nginx/conf.d; then + echo "http still binds public 443" >&2 + exit 1 + fi + nginx -t + systemctl reload nginx +} + +mkdir -p "$BACKUP" +cp -a /etc/nginx "$BACKUP/nginx" +echo "backup $BACKUP" + +if ! need_certs; then + install_acme + issue_certs +fi + +if ! need_certs; then + echo "certificates missing" >&2 + exit 1 +fi + +install_sni +echo "nginx sni ready" diff --git a/deploy/nginx/nginx.conf b/deploy/nginx/nginx.conf new file mode 100644 index 0000000..52366a6 --- /dev/null +++ b/deploy/nginx/nginx.conf @@ -0,0 +1,10 @@ +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /run/nginx.pid; + +events { + worker_connections 1024; +} + +include /etc/nginx/stream.conf; +include /etc/nginx/http.conf; diff --git a/deploy/nginx/stream.conf b/deploy/nginx/stream.conf index 700120c..f41aab2 100644 --- a/deploy/nginx/stream.conf +++ b/deploy/nginx/stream.conf @@ -1,3 +1,7 @@ +# Public :443 is stream ssl_preread (SNI). Unknown SNI must not fall through to HTTP. +# IM TLS is terminated here (gnet v2 has no native TLS), then PROXY+plaintext to 127.0.0.1:9000. +# HTTP TLS is terminated on 127.0.0.1:8443 (see http.conf). + stream { map $ssl_preread_server_name $backend { api.example.com 127.0.0.1:8443; @@ -13,12 +17,14 @@ stream { proxy_pass $backend; } - # Add PROXY protocol only on the IM hop so Gin/HTTP is unaffected. server { - listen 127.0.0.1:9001; - proxy_protocol on; + listen 127.0.0.1:9001 ssl; + ssl_certificate /etc/nginx/certs/im.example.com.crt; + ssl_certificate_key /etc/nginx/certs/im.example.com.key; + ssl_protocols TLSv1.2 TLSv1.3; proxy_connect_timeout 5s; proxy_timeout 3600s; + proxy_protocol on; proxy_pass 127.0.0.1:9000; } } diff --git a/docs/architecture-v3.md b/docs/architecture-v3.md index 73811cc..945c582 100644 --- a/docs/architecture-v3.md +++ b/docs/architecture-v3.md @@ -283,16 +283,16 @@ type TaskPool interface { 不学 BFF 握手加密、双版本头、把 Response 当 Request。 ```text -TCP → PROXY → TLS 1.2+ → Frame → Protobuf +TCP → Nginx TLS → PROXY → gnet Frame → JSON envelope ``` ```text Magic u16 | Version u8 | Command u16 | Flags u16 RequestID u64 | SequenceID u32 | BodyLength u32 -Protobuf Body +JSON Body {"code","msg","data"} ``` -`Flags` 含 `Compress`。Body 超过 512B 再压缩(阈值可配),对齐 BFF `CompressMinSize`。 +Body **不是 Protobuf**。`Flags` 含 `Compress`。Body 超过 512B 再压缩(阈值可配)。 `SequenceID` 只匹配请求/响应和 PUSH_ACK。 `MaxFrameSize = 64KB`。超大 `BodyLength` 断开。媒体不走 Frame。 @@ -407,19 +407,21 @@ P2P 好友关系、未拉黑、对方未禁你 ```sql CREATE TABLE messages ( - id BIGINT PRIMARY KEY, - conversation_id BIGINT NOT NULL, - sender_id BIGINT NOT NULL, - client_msg_id VARCHAR(64) NOT NULL, - message_type SMALLINT NOT NULL, - content VARBINARY(65535) NOT NULL, - status TINYINT NOT NULL DEFAULT 1, - created_at DATETIME(3) NOT NULL, + id BIGINT PRIMARY KEY COMMENT 'Snowflake MsgId,主键、排序键、同步游标', + conversation_id BIGINT NOT NULL COMMENT '所属会话 id', + sender_id BIGINT NOT NULL COMMENT '发送者 user_id', + client_msg_id VARCHAR(64) NOT NULL COMMENT '客户端消息 id,与 sender+会话 组成幂等键', + message_type SMALLINT NOT NULL COMMENT '1文本 2图片 3语音 4视频 5表情 6GIF 7文件 8位置 9系统', + content BLOB NOT NULL COMMENT '序列化 JSON 字节,禁止存原始 string;媒体只存对象存储 key', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 2=已撤回 3=审核隐藏', + created_at DATETIME(3) NOT NULL COMMENT '服务端入库时间', UNIQUE KEY uk_sender_conv_client_msg (sender_id, conversation_id, client_msg_id), KEY idx_conversation_id (conversation_id, id) -); +) COMMENT='消息正文。PK=MsgId;content 为序列化 BLOB'; ``` +`message_type` 与 `content` 内 JSON 的 `t` 必须一致。正文用 `json.Marshal` 得到的 `[]byte` 写入 BLOB,不要把 Go `string` 当列类型。图片/语音/GIF/文件只存对象存储 key。完整 COMMENT 见 `migrations/0001_init.sql`。 + ```sql SELECT * FROM messages WHERE conversation_id = ? AND id > ? @@ -458,7 +460,7 @@ CREATE TABLE conversation_members ( ``` `last_read_msg_id` 在 MySQL,作为已读真相(重启 Redis 可从这里重建未读)。 -会话列表不扫这张表的热路径。 +会话列表不扫这张表的热路径。列 COMMENT 见 `migrations/0001_init.sql`。 不做 `message_receipts`。 @@ -488,7 +490,7 @@ CREATE TABLE device_sync_cursors ( ); ``` -**不**给每条成功 Push 的新消息写 mailbox。对齐 BFF ICH:邮箱只服务没推上的设备,以及撤回 / 踢人 / 好友这类控制事件。 +**不**给每条成功 Push 的新消息写 mailbox。对齐 BFF ICH:邮箱只服务没推上的设备,以及撤回 / 踢人 / 好友这类控制事件。列 COMMENT 见 `migrations/0001_init.sql`。 --- diff --git a/examples/android/.gitignore b/examples/android/.gitignore new file mode 100644 index 0000000..a22daf1 --- /dev/null +++ b/examples/android/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +**/build/ +local.properties +*.apk +.idea/ diff --git a/examples/android/app/build.gradle.kts b/examples/android/app/build.gradle.kts new file mode 100644 index 0000000..6239952 --- /dev/null +++ b/examples/android/app/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.spark.check" + compileSdk = 35 + + defaultConfig { + applicationId = "com.spark.check" + minSdk = 24 + targetSdk = 35 + versionCode = 2 + versionName = "0.1.1" + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} diff --git a/examples/android/app/src/main/AndroidManifest.xml b/examples/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1cfeedb --- /dev/null +++ b/examples/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + diff --git a/examples/android/app/src/main/java/com/spark/check/CheckActivity.kt b/examples/android/app/src/main/java/com/spark/check/CheckActivity.kt new file mode 100644 index 0000000..3c1b3f7 --- /dev/null +++ b/examples/android/app/src/main/java/com/spark/check/CheckActivity.kt @@ -0,0 +1,88 @@ +package com.spark.check + +import android.app.Activity +import android.graphics.Typeface +import android.os.Bundle +import android.util.TypedValue +import android.view.ViewGroup.LayoutParams +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import kotlin.concurrent.thread + +class CheckActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val pad = dp(16) + val url = EditText(this).apply { + setText(SparkClient.DEFAULT_BASE) + hint = "http://10.0.2.2:8080" + setSingleLine() + setPadding(pad, pad, pad, pad) + } + val log = TextView(this).apply { + setPadding(pad, pad, pad, pad) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f) + typeface = Typeface.MONOSPACE + text = "电脑先 make start,再选地址后点「开始校验」。" + } + val run = Button(this).apply { text = "开始校验" } + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(pad, pad, pad, pad) + addView(label("服务地址")) + addView(url) + addView(row( + preset("官方模拟器", SparkClient.DEFAULT_BASE, url), + preset("夜神", SparkClient.NOX_BASE, url), + )) + addView(preset("真机 (adb reverse)", SparkClient.DEVICE_BASE, url)) + addView(run) + addView(log) + } + setContentView(ScrollView(this).apply { + isFillViewport = true + addView(root) + }) + run.setOnClickListener { + val base = SparkClient.normalizeBase(url.text.toString()) + url.setText(base) + run.isEnabled = false + log.text = "请求 $base …" + thread { + val text = try { + SparkClient(base).check() + } catch (e: Exception) { + e.message ?: e.toString() + } + runOnUiThread { + log.text = text + run.isEnabled = true + } + } + } + } + + private fun preset(title: String, value: String, url: EditText): Button = + Button(this).apply { + text = title + setOnClickListener { url.setText(value) } + } + + private fun row(a: Button, b: Button): LinearLayout = + LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + addView(a, LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + addView(b, LinearLayout.LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + } + + private fun label(text: String): TextView = + TextView(this).apply { + this.text = text + setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) + } + + private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt() +} diff --git a/examples/android/app/src/main/java/com/spark/check/SparkClient.kt b/examples/android/app/src/main/java/com/spark/check/SparkClient.kt new file mode 100644 index 0000000..7b726a2 --- /dev/null +++ b/examples/android/app/src/main/java/com/spark/check/SparkClient.kt @@ -0,0 +1,174 @@ +package com.spark.check + +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.StandardCharsets +import java.util.UUID + +/** + * 对着本机 spark-server 校验登录 / 资料。 + * + * 服务端 Gin 只绑 127.0.0.1:8080: + * - 模拟器:BASE = http://10.0.2.2:8080 + * - 真机:先 `adb reverse tcp:8080 tcp:8080`,再 BASE = http://127.0.0.1:8080 + * + * 先在仓库根目录 `make start`。测试号任意验证码即可。 + */ +class SparkClient( + base: String = DEFAULT_BASE, + private val deviceId: Long = DEVICE_ID, +) { + private val base: String = normalizeBase(base) + data class Envelope(val code: String, val msg: String, val data: JSONObject) + data class Token( + val access: String, + val refresh: String, + val userId: Long, + val deviceId: Long, + val sparkId: String, + val expiresIn: Long, + ) + + fun healthz(): Envelope = get("/healthz") + + fun login(email: String = TEST_EMAIL, verifyCode: String = "000000"): Token { + val body = JSONObject() + .put("email", email) + .put("verify_code", verifyCode) + .put("device_id", deviceId) + .put("device_type", "android") + .put("device_name", "SparkCheck") + val env = post("/v1/auth/login", body) + requireOk(env, "login") + val d = env.data + return Token( + access = d.getString("access_token"), + refresh = d.getString("refresh_token"), + userId = d.getLong("user_id"), + deviceId = d.getLong("device_id"), + sparkId = d.optString("spark_id"), + expiresIn = d.optLong("expires_in"), + ) + } + + fun me(access: String): Envelope = get("/v1/users/me", access) + + fun refresh(refreshToken: String): Token { + val env = post("/v1/auth/token/refresh", JSONObject().put("refresh_token", refreshToken)) + requireOk(env, "refresh") + val d = env.data + return Token( + access = d.getString("access_token"), + refresh = d.getString("refresh_token"), + userId = d.getLong("user_id"), + deviceId = d.getLong("device_id"), + sparkId = d.optString("spark_id"), + expiresIn = d.optLong("expires_in"), + ) + } + + fun logout(access: String): Envelope = post("/v1/auth/logout", JSONObject(), access) + + fun check(): String { + val out = StringBuilder() + fun line(title: String, env: Envelope) { + out.append(title).append(" code=").append(env.code) + .append(" msg=").append(env.msg) + .append('\n').append(env.data.toString(2)).append("\n\n") + } + line("GET /healthz", healthz()) + val tok = login() + out.append("login user_id=").append(tok.userId) + .append(" spark_id=").append(tok.sparkId) + .append(" expires_in=").append(tok.expiresIn).append("\n\n") + val profile = me(tok.access) + requireOk(profile, "me") + line("GET /v1/users/me", profile) + val next = refresh(tok.refresh) + out.append("refresh user_id=").append(next.userId).append("\n\n") + line("POST /v1/auth/logout", logout(next.access)) + return out.toString() + } + + private fun get(path: String, access: String? = null): Envelope = + call("GET", path, null, access) + + private fun post(path: String, body: JSONObject, access: String? = null): Envelope = + call("POST", path, body, access) + + private fun call(method: String, path: String, body: JSONObject?, access: String?): Envelope { + val conn = try { + (URL(base + path).openConnection() as HttpURLConnection).apply { + requestMethod = method + connectTimeout = 8_000 + readTimeout = 15_000 + instanceFollowRedirects = true + setRequestProperty("Accept", "application/json") + setRequestProperty("Content-Type", "application/json; charset=utf-8") + setRequestProperty("X-Language", "zh-CN") + setRequestProperty("X-App-Version", "0.1.1-check") + setRequestProperty("X-Request-ID", UUID.randomUUID().toString()) + setRequestProperty("X-Device-ID", deviceId.toString()) + if (!access.isNullOrEmpty()) { + setRequestProperty("Authorization", "Bearer $access") + } + if (body != null) { + doOutput = true + outputStream.use { it.write(body.toString().toByteArray(StandardCharsets.UTF_8)) } + } + } + } catch (e: Exception) { + throw IllegalStateException(connectHint(base, e), e) + } + val raw = try { + read(if (conn.responseCode in 200..299) conn.inputStream else conn.errorStream) + } catch (e: Exception) { + throw IllegalStateException(connectHint(base, e), e) + } finally { + conn.disconnect() + } + val json = JSONObject(raw.ifBlank { "{}" }) + val data = json.opt("data") + return Envelope( + code = json.optString("code", ""), + msg = json.optString("msg", ""), + data = if (data is JSONObject) data else JSONObject(), + ) + } + + companion object { + const val DEFAULT_BASE = "http://10.0.2.2:8080" + const val NOX_BASE = "http://10.0.3.2:8080" + const val DEVICE_BASE = "http://127.0.0.1:8080" + const val TEST_EMAIL = "test01@spark.com" + const val DEVICE_ID = 10001L + + fun normalizeBase(raw: String): String { + var s = raw.trim() + if (s.isEmpty()) return DEFAULT_BASE + if (!s.contains("://")) s = "http://$s" + return s.trimEnd('/') + } + + fun connectHint(base: String, e: Exception): String { + return "连不上 $base(${e.javaClass.simpleName}: ${e.message})\n\n" + + "1. 电脑仓库里先 make start\n" + + "2. 官方模拟器:http://10.0.2.2:8080\n" + + "3. 夜神模拟器:http://10.0.3.2:8080\n" + + "4. 真机:电脑执行 adb reverse tcp:8080 tcp:8080,地址填 http://127.0.0.1:8080\n" + + "(服务只监听电脑 127.0.0.1,填局域网 IP 也进不去)" + } + + private fun requireOk(env: Envelope, what: String) { + check(env.code == "0") { "$what failed code=${env.code} msg=${env.msg}" } + } + + private fun read(stream: InputStream?): String { + if (stream == null) return "" + return stream.bufferedReader(StandardCharsets.UTF_8).use(BufferedReader::readText) + } + } +} diff --git a/examples/android/app/src/main/res/drawable/ic_launcher.xml b/examples/android/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..9ba4de0 --- /dev/null +++ b/examples/android/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/examples/android/app/src/main/res/xml/network_security_config.xml b/examples/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..d7b4192 --- /dev/null +++ b/examples/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/examples/android/build.gradle.kts b/examples/android/build.gradle.kts new file mode 100644 index 0000000..c7ad754 --- /dev/null +++ b/examples/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false +} diff --git a/examples/android/gradle.properties b/examples/android/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/examples/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/examples/android/gradle/wrapper/gradle-wrapper.jar b/examples/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..2c3521197d7c4586c843d1d3e9090525f1898cde GIT binary patch literal 43504 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 TZiJi*F`$tkWLn) literal 0 HcmV?d00001 diff --git a/examples/android/gradle/wrapper/gradle-wrapper.properties b/examples/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a659fde --- /dev/null +++ b/examples/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=120000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/android/gradlew b/examples/android/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/examples/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/android/gradlew.bat b/examples/android/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/examples/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/android/settings.gradle.kts b/examples/android/settings.gradle.kts new file mode 100644 index 0000000..3e62155 --- /dev/null +++ b/examples/android/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "SparkCheck" +include(":app") diff --git a/go.mod b/go.mod index 7afc08c..63acd0e 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,35 @@ module spark-server go 1.26.0 require ( + github.com/aws/aws-sdk-go-v2 v1.47.0 + github.com/aws/aws-sdk-go-v2/credentials v1.20.5 + github.com/aws/aws-sdk-go-v2/service/s3 v1.113.1 + github.com/aws/smithy-go v1.28.1 github.com/bwmarrin/snowflake v0.3.0 github.com/gin-gonic/gin v1.12.0 + github.com/go-playground/validator/v10 v10.30.1 github.com/go-sql-driver/mysql v1.10.1 + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/panjf2000/gnet/v2 v2.10.0 github.com/redis/go-redis/v9 v9.22.0 - google.golang.org/protobuf v1.36.10 + github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569 + go.uber.org/zap v1.28.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 + gorm.io/driver/mysql v1.5.7 + gorm.io/gorm v1.30.0 ) require ( filippo.io/edwards25519 v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect @@ -23,31 +41,34 @@ require ( github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/panjf2000/ants/v2 v2.12.1 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.28.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/image v0.32.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/go.sum b/go.sum index 85a3d86..892975a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,29 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/aws/aws-sdk-go-v2 v1.47.0 h1:0jsHallhJCeaU0Ko48c/3FK1ctOQ7NpzggxriJOQ8MQ= +github.com/aws/aws-sdk-go-v2 v1.47.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8= +github.com/aws/aws-sdk-go-v2/credentials v1.20.5 h1:wklUVvHMc9xTQ3rcp49/ISpiMnhbCicJcA6n6S8m7J8= +github.com/aws/aws-sdk-go-v2/credentials v1.20.5/go.mod h1:fyEdrn6ccLFOkoK84j5bQyGTxp9zPt5l2XMhxf4DVZs= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 h1:Hp/VgjP0BysR3OgLlR057Vz2LcbbVnoWeJ+3qWiS/fY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3/go.mod h1:nwGV5qw7F1IZPgxCvA/ph8N2TAuz+BkRG/bXn808qMA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 h1:MUaM4f+kj1ZIBPZfUS8cxP1GKXXZtHJjAthy93AN7SM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3/go.mod h1:6YmVmEVRI5ZZzRjCSsb9SryKH0hAlMRdgA7kG9aDvBU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 h1:fuSCw4Z2qfRCztMPO3GXJNSiEp6Wee+WOLwrHHUMy9c= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3/go.mod h1:6SxcHheD1pPR5+kWm1wGvjlL/YqUsh267sAfEmN4K7A= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3 h1:BHKCSX4QXERe8So8rbWqaM7owqOmDJxATXgJwGng22A= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3/go.mod h1:GqWeeKfYfezihA2KfFL9l7ohEdZWe1tuFWh3GfyNSnE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 h1:bON1rJf67TSTDCKg816AAIE4xSTtoo9tl0XRkO72R+I= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3/go.mod h1:c5BBpjJcQXpfeq9iASyVKA3T6vX6B6LEXY4mL/gklDY= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3 h1:L8vIOxylma91TcR96NFTEC07G3JDwSl+CvK2b+IODms= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3/go.mod h1:fmPIZQzTExYuBNWFyi1P7IoDjvskgphXqK1yObMzusM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.113.1 h1:cFHmwLxZPvtoAlo79MboURL2+7b0TMHycnrcf5VZNXk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.113.1/go.mod h1:/uA+2Qj4jd5qBWagVC1AyzzDFXVK997E7U04w7Kw0wI= +github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= +github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -33,19 +57,26 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME= github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -63,8 +94,8 @@ github.com/panjf2000/ants/v2 v2.12.1 h1:BWvU2wHpyXWxhhNXsGB6JXLCNbshyLd1QxvoAmZn github.com/panjf2000/ants/v2 v2.12.1/go.mod h1:tSQuaNQ6r6NRhPt+IZVUevvDyFMTs+eS4ztZc52uJTY= github.com/panjf2000/gnet/v2 v2.10.0 h1:rC4jNF+jtXj/FH+8JOIQ3XxjD+yBunYBLKg9TE3dc4g= github.com/panjf2000/gnet/v2 v2.10.0/go.mod h1:f9wdbOFsdbZqlSvXctWbPRW5bB/W++q8Zqz+D7tQIVQ= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= @@ -73,8 +104,8 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -86,6 +117,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569 h1:xzABM9let0HLLqFypcxvLmlvEciCHL7+Lv+4vwZqecI= +github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569/go.mod h1:2Ly+NIftZN4de9zRmENdYbvPQeaVIYKWpLFStLFEBgI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= @@ -106,21 +139,23 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ= +golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -131,3 +166,8 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= diff --git a/internal/bootstrap/app.go b/internal/bootstrap/app.go index 5cd944a..13e22b1 100644 --- a/internal/bootstrap/app.go +++ b/internal/bootstrap/app.go @@ -5,56 +5,161 @@ import ( "crypto/tls" "errors" "fmt" - "log/slog" "net/http" "os" "os/signal" + "sync" + "sync/atomic" "syscall" "time" + "go.uber.org/zap" + "spark-server/internal/infrastructure/id" "spark-server/internal/infrastructure/logger" "spark-server/internal/infrastructure/mysql" + "spark-server/internal/infrastructure/objectstore" redisx "spark-server/internal/infrastructure/redis" + authlogic "spark-server/internal/module/auth/logic" + convlogic "spark-server/internal/module/conversation/logic" + convmodel "spark-server/internal/module/conversation/model" + deliverlogic "spark-server/internal/module/deliver/logic" + delivermodel "spark-server/internal/module/deliver/model" + favlogic "spark-server/internal/module/favorite/logic" + favmodel "spark-server/internal/module/favorite/model" + friendlogic "spark-server/internal/module/friend/logic" + friendmodel "spark-server/internal/module/friend/model" + grouplogic "spark-server/internal/module/group/logic" + groupmodel "spark-server/internal/module/group/model" + msgmodel "spark-server/internal/module/message/model" + uploadlogic "spark-server/internal/module/upload/logic" + userlogic "spark-server/internal/module/user/logic" + usermodel "spark-server/internal/module/user/model" + "spark-server/internal/pkg/ratelimit" httpx "spark-server/internal/transport/http" tcp "spark-server/internal/transport/tcp" "spark-server/internal/transport/tcp/session" ) type App struct { - cfg *Config - log *slog.Logger - http *httpx.Server - tcp *tcp.Server - pool tcp.TaskPool - mysql *mysql.DB - redis *redisx.Client - idgen *id.Generator - sessions session.Manager + cfg *Config + log *zap.Logger + logClose func() error + http *httpx.Server + tcp *tcp.Server + pool tcp.TaskPool + mysql *mysql.DB + redis *redisx.Client + idgen *id.Generator + sessions session.Manager + deliver *deliverlogic.Worker + accepting atomic.Bool + shutdownOnce sync.Once } func New(cfg *Config) (*App, error) { - log := logger.New() + log, logClose := logger.New(&logger.Options{ + Level: cfg.Log.Level, + Encoding: cfg.Log.Encoding, + Console: cfg.Log.Console, + File: logger.FileOptions{ + Path: cfg.Log.File.Path, + MaxSizeMB: cfg.Log.File.MaxSizeMB, + MaxBackups: cfg.Log.File.MaxBackups, + MaxAgeDays: cfg.Log.File.MaxAgeDays, + Compress: cfg.Log.File.Compress, + }, + }) idgen, err := id.New(cfg.ID.SnowflakeWorkerID) if err != nil { + _ = logClose() return nil, err } - db, err := mysql.Open(cfg.MySQL.DSN) + db, err := mysql.Open(log, &mysql.Options{ + DSN: cfg.MySQL.DSN, + SlowThreshold: cfg.MySQL.SlowThreshold, + LogLevel: cfg.MySQL.LogLevel, + ZapLevel: cfg.Log.Level, + MaxOpenConns: cfg.MySQL.MaxOpenConns, + MaxIdleConns: cfg.MySQL.MaxIdleConns, + ConnMaxLifetime: cfg.MySQL.ConnMaxLifetime, + ConnMaxIdleTime: cfg.MySQL.ConnMaxIdleTime, + }) if err != nil { + _ = logClose() return nil, err } - rdb, err := redisx.Open(cfg.Redis.Addr) + rdb, err := redisx.Open(&redisx.Options{ + Addr: cfg.Redis.Addr, + Username: cfg.Redis.Username, + Password: cfg.Redis.Password, + DB: cfg.Redis.DB, + }) if err != nil { _ = db.Close() + _ = logClose() return nil, err } - pool := tcp.NewTaskPool(cfg.TaskPool.Size, cfg.TaskPool.Queue) + return finishApp(cfg, log, logClose, idgen, db, rdb) +} + +func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Generator, db *mysql.DB, rdb *redisx.Client) (*App, error) { + jwtSvc, err := authlogic.NewJWT(&authlogic.Options{ + Secret: cfg.JWT.Secret, + Issuer: cfg.JWT.Issuer, + TTL: cfg.JWT.TTL, + RefreshTTL: cfg.JWT.RefreshTTL, + Denylist: jwtDenylist(rdb, cfg.JWT.DenylistPrefix), + Pairs: jwtPairs(rdb, cfg.JWT.RefreshPrefix), + }) + if err != nil { + _ = rdb.Close() + _ = db.Close() + _ = logClose() + return nil, err + } + pool := tcp.NewTaskPoolRecover(cfg.TaskPool.Size, cfg.TaskPool.Queue, func(rec any) { + log.Error("taskpool panic", zap.Any("panic", rec)) + }) sessions := session.NewManager() tlsConf, err := loadTLS(cfg.Server.TCP.TLS) if err != nil { + pool.Close() + _ = rdb.Close() + _ = db.Close() + _ = logClose() return nil, err } - tcpSrv := tcp.NewServer(log, pool, sessions, &tcp.Options{ + authLimit := ratelimit.New(cfg.RateLimit.AuthPerSecond) + objects, err := objectstore.Open(&objectstore.Options{ + Endpoint: cfg.ObjectStorage.Endpoint, + AccessKey: cfg.ObjectStorage.AccessKey, + SecretKey: cfg.ObjectStorage.SecretKey, + Bucket: cfg.ObjectStorage.Bucket, + Region: cfg.ObjectStorage.Region, + UseSSL: cfg.ObjectStorage.UseSSL, + ChatExpireDays: cfg.ObjectStorage.ChatExpireDays, + Log: log, + }) + if err != nil { + pool.Close() + _ = rdb.Close() + _ = db.Close() + _ = logClose() + return nil, err + } + app := &App{cfg: cfg, log: log, logClose: logClose, pool: pool, mysql: db, redis: rdb, idgen: idgen, sessions: sessions} + probe := &Probe{mysql: db, redis: rdb, objects: objects, pool: pool, accepting: &app.accepting, requireDeps: cfg.Production()} + users := wireUsers(idgen, db, rdb, objects) + login := authlogic.NewLoginService(jwtSvc, users) + login.Sessions = sessions + var deliverQueue deliverlogic.Queue + app.deliver, deliverQueue = wireDeliver(cfg, log, rdb, sessions) + friends := wireFriends(idgen, db, users, deliverQueue) + groups := wireGroups(idgen, db, users, friends, deliverQueue, objects) + files := wireFiles(cfg, objects, idgen, groups) + favorites := wireFavorites(idgen, db, objects) + app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{ Addr: cfg.Server.TCP.Addr, Multicore: cfg.Server.TCP.Multicore, KeepAlive: cfg.Server.TCP.KeepAlive, @@ -68,18 +173,21 @@ func New(cfg *Config) (*App, error) { RatePerSec: cfg.RateLimit.MessagePerSecond, CompressMin: cfg.Server.TCP.CompressMinSize, IDs: idgen, + Auth: jwtSvc, + AuthLimiter: authLimit, }) - return &App{ - cfg: cfg, - log: log, - http: httpx.New(log, cfg.Server.HTTP.Addr), - tcp: tcpSrv, - pool: pool, - mysql: db, - redis: rdb, - idgen: idgen, - sessions: sessions, - }, nil + app.http = httpx.New(log, cfg.Server.HTTP.Addr, &httpx.EngineDeps{ + Auth: jwtSvc, + Login: login, + Users: users, + Friends: friends, + Groups: groups, + Files: files, + Favorites: favorites, + Health: probe, + AuthLimiter: authLimit, + }) + return app, nil } func loadTLS(t TLSConfig) (*tls.Config, error) { @@ -93,23 +201,179 @@ func loadTLS(t TLSConfig) (*tls.Config, error) { return &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{cert}}, nil } +func wireUsers(idgen *id.Generator, db *mysql.DB, rdb *redisx.Client, store *objectstore.Store) *userlogic.Service { + var repo userlogic.Repository + if db != nil && db.Ready() { + repo = usermodel.NewRepo(db.Gorm()) + } else { + repo = usermodel.NewMemoryRepo() + } + var codes userlogic.CodeStore + if rdb != nil && rdb.Ready() { + codes = usermodel.NewRedisCodes(rdb.RDB()) + } else { + codes = usermodel.NewMemoryCodes() + } + var objects userlogic.Objects + if store != nil && store.Ready() { + objects = store + } + return userlogic.NewService(repo, codes, idgen, objects) +} + +func wireFriends(idgen *id.Generator, db *mysql.DB, users *userlogic.Service, q friendlogic.DeliverQueue) *friendlogic.Service { + var repo friendlogic.Repository + var chats friendlogic.P2PChats + var msgs friendlogic.Messages + if db != nil && db.Ready() { + repo = friendmodel.NewRepo(db.Gorm()) + chats = convlogic.NewP2PService(convmodel.NewRepo(db.Gorm()), idgen) + msgs = msgmodel.NewRepo(db.Gorm()) + } else { + repo = friendmodel.NewMemoryRepo() + chats = convlogic.NewP2PService(convmodel.NewMemoryP2P(), idgen) + msgs = msgmodel.NewMemoryRepo() + } + return friendlogic.NewService(repo, users, idgen, friendlogic.NewGreeter(chats, msgs, idgen, q)) +} + +func wireGroups(idgen *id.Generator, db *mysql.DB, users *userlogic.Service, friends *friendlogic.Service, q grouplogic.DeliverQueue, store *objectstore.Store) *grouplogic.Service { + var repo grouplogic.Repository + var msgs grouplogic.Messages + if db != nil && db.Ready() { + repo = groupmodel.NewRepo(db.Gorm()) + msgs = msgmodel.NewRepo(db.Gorm()) + } else { + repo = groupmodel.NewMemoryRepo() + msgs = msgmodel.NewMemoryRepo() + } + var objects grouplogic.Objects + if store != nil && store.Ready() { + objects = store + } + return grouplogic.NewService(repo, users, friends, idgen, msgs, q, objects) +} + +func wireFiles(cfg *Config, store *objectstore.Store, idgen *id.Generator, groups *grouplogic.Service) *uploadlogic.Service { + if store == nil || !store.Ready() || idgen == nil { + return nil + } + return uploadlogic.NewService(store, idgen, uploadlogic.Options{ + TTL: cfg.ObjectStorage.PresignTTL, + Limit: ratelimit.New(cfg.RateLimit.UploadPerSecond), + Groups: groups, + }) +} + +func wireFavorites(idgen *id.Generator, db *mysql.DB, store *objectstore.Store) *favlogic.Service { + if idgen == nil { + return nil + } + var repo favlogic.Repository + var msgs favlogic.Messages + var members favlogic.Members + if db != nil && db.Ready() { + repo = favmodel.NewRepo(db.Gorm()) + msgs = msgmodel.NewRepo(db.Gorm()) + members = convmodel.NewRepo(db.Gorm()) + } else { + repo = favmodel.NewMemoryRepo() + msgs = msgmodel.NewMemoryRepo() + members = convmodel.NewMemoryP2P() + } + var objects favlogic.Objects + if store != nil && store.Ready() { + objects = store + } + return favlogic.NewService(repo, msgs, members, objects, idgen) +} + +func wireDeliver(cfg *Config, log *zap.Logger, rdb *redisx.Client, sessions session.Manager) (*deliverlogic.Worker, deliverlogic.Queue) { + var q deliverlogic.Queue + var done deliverlogic.Deduper + if rdb != nil && rdb.Ready() { + q = delivermodel.NewRedisQueue(rdb.RDB(), cfg.Redis.DeliverQueueKey, cfg.Redis.DeliverProcessingKey) + done = delivermodel.NewRedisDone(rdb.RDB(), cfg.Redis.DeliverDonePrefix, cfg.Deliver.DoneTTL) + } else { + q = delivermodel.NewMemoryQueue() + done = delivermodel.NewMemoryDone() + } + return deliverlogic.NewWorker(log, q, done, deliverlogic.WorkerOptions{ + Workers: cfg.Deliver.Workers, + TakeTimeout: cfg.Deliver.TakeTimeout, + Handler: deliverlogic.SessionFanout(sessions), + }), q +} + +func jwtDenylist(rdb *redisx.Client, prefix string) authlogic.Denylist { + if rdb != nil && rdb.Ready() { + return authlogic.NewRedisDenylist(rdb.RDB(), prefix) + } + return authlogic.NewMemoryDenylist() +} + +func jwtPairs(rdb *redisx.Client, prefix string) authlogic.PairStore { + if rdb != nil && rdb.Ready() { + return authlogic.NewRedisPairs(rdb.RDB(), prefix) + } + return authlogic.NewMemoryPairs() +} + func (a *App) Run() error { + if a.deliver != nil { + a.deliver.Start(context.Background()) + } errCh := make(chan error, 2) go func() { errCh <- a.http.Start() }() go func() { errCh <- a.tcp.Start() }() + if err := a.waitListening(errCh); err != nil { + return errors.Join(err, a.Shutdown()) + } + a.accepting.Store(true) + a.log.Info("ready") + return a.serve(errCh) +} - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) +func (a *App) waitListening(errCh <-chan error) error { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { + done <- errors.Join(a.http.WaitReady(ctx), a.tcp.WaitReady(ctx)) + }() select { case err := <-errCh: + if err == nil { + return ctx.Err() + } return err + case err := <-done: + return err + } +} + +func (a *App) serve(errCh <-chan error) error { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigCh) + select { + case err := <-errCh: + a.log.Error("server exit", zap.Error(err)) + return errors.Join(err, a.Shutdown()) case sig := <-sigCh: - a.log.Info("shutdown", "signal", sig.String()) + a.log.Info("shutdown", zap.String("signal", sig.String())) return a.Shutdown() } } func (a *App) Shutdown() error { + var err error + a.shutdownOnce.Do(func() { err = a.doShutdown() }) + return err +} + +func (a *App) doShutdown() error { + a.accepting.Store(false) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() var err error @@ -119,7 +383,15 @@ func (a *App) Shutdown() error { if e := a.tcp.Stop(ctx); e != nil { err = errors.Join(err, e) } - a.pool.Close() + if a.pool != nil { + a.pool.Close() + } + if a.deliver != nil { + a.deliver.Stop() + } err = errors.Join(err, a.redis.Close(), a.mysql.Close()) + if a.logClose != nil { + err = errors.Join(err, a.logClose()) + } return err } diff --git a/internal/bootstrap/config.go b/internal/bootstrap/config.go index 3f78e8f..fe88b35 100644 --- a/internal/bootstrap/config.go +++ b/internal/bootstrap/config.go @@ -4,21 +4,41 @@ package bootstrap import ( "fmt" "os" + "strings" "time" "gopkg.in/yaml.v3" ) type Config struct { - Server ServerConfig `yaml:"server"` - ID IDConfig `yaml:"id"` - MySQL MySQLConfig `yaml:"mysql"` - Redis RedisConfig `yaml:"redis"` - TaskPool TaskPoolConfig `yaml:"taskpool"` - Deliver DeliverConfig `yaml:"deliver"` - Session SessionConfig `yaml:"session"` - RateLimit RateLimitConfig `yaml:"rate_limit"` - Message MessageConfig `yaml:"message"` + Env string `yaml:"env"` + Server ServerConfig `yaml:"server"` + Log LogConfig `yaml:"log"` + ID IDConfig `yaml:"id"` + MySQL MySQLConfig `yaml:"mysql"` + Redis RedisConfig `yaml:"redis"` + JWT JWTConfig `yaml:"jwt"` + TaskPool TaskPoolConfig `yaml:"taskpool"` + Deliver DeliverConfig `yaml:"deliver"` + Session SessionConfig `yaml:"session"` + RateLimit RateLimitConfig `yaml:"rate_limit"` + Message MessageConfig `yaml:"message"` + ObjectStorage ObjectStorageConfig `yaml:"object_storage"` +} + +type LogConfig struct { + Level string `yaml:"level"` + Encoding string `yaml:"encoding"` + Console bool `yaml:"console"` + File LogFileConfig `yaml:"file"` +} + +type LogFileConfig struct { + Path string `yaml:"path"` + MaxSizeMB int `yaml:"max_size_mb"` + MaxBackups int `yaml:"max_backups"` + MaxAgeDays int `yaml:"max_age_days"` + Compress bool `yaml:"compress"` } type ServerConfig struct { @@ -53,13 +73,23 @@ type IDConfig struct { } type MySQLConfig struct { - DSN string `yaml:"dsn"` + DSN string `yaml:"dsn"` + SlowThreshold time.Duration `yaml:"slow_threshold"` + LogLevel string `yaml:"log_level"` + MaxOpenConns int `yaml:"max_open_conns"` + MaxIdleConns int `yaml:"max_idle_conns"` + ConnMaxLifetime time.Duration `yaml:"conn_max_lifetime"` + ConnMaxIdleTime time.Duration `yaml:"conn_max_idle_time"` } type RedisConfig struct { Addr string `yaml:"addr"` + Username string `yaml:"username"` + Password string `yaml:"password"` + DB int `yaml:"db"` DeliverQueueKey string `yaml:"deliver_queue_key"` DeliverProcessingKey string `yaml:"deliver_processing_key"` + DeliverDonePrefix string `yaml:"deliver_done_prefix"` } type TaskPoolConfig struct { @@ -83,6 +113,18 @@ type RateLimitConfig struct { SyncInboxPerMinute int `yaml:"sync_inbox_per_minute"` SyncMessagesPerMinute int `yaml:"sync_messages_per_minute"` HistoryPerMinute int `yaml:"history_per_minute"` + UploadPerSecond int `yaml:"upload_per_second"` +} + +type ObjectStorageConfig struct { + Endpoint string `yaml:"endpoint"` + AccessKey string `yaml:"access_key"` + SecretKey string `yaml:"secret_key"` + Bucket string `yaml:"bucket"` + Region string `yaml:"region"` + UseSSL bool `yaml:"use_ssl"` + PresignTTL time.Duration `yaml:"presign_ttl"` + ChatExpireDays int `yaml:"chat_expire_days"` } type MessageConfig struct { @@ -91,6 +133,15 @@ type MessageConfig struct { MsgIDExpireMS int64 `yaml:"msgid_expire_ms"` } +type JWTConfig struct { + Secret string `yaml:"secret"` + Issuer string `yaml:"issuer"` + TTL time.Duration `yaml:"ttl"` + RefreshTTL time.Duration `yaml:"refresh_ttl"` + DenylistPrefix string `yaml:"denylist_prefix"` + RefreshPrefix string `yaml:"refresh_prefix"` +} + func Load(path string) (*Config, error) { raw, err := os.ReadFile(path) if err != nil { @@ -100,5 +151,136 @@ func Load(path string) (*Config, error) { if err := yaml.Unmarshal(raw, cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } + if err := cfg.normalize(); err != nil { + return nil, err + } return cfg, nil } + +const ( + EnvDev = "dev" + EnvProd = "prod" + // DefaultJWTSecret is rejected when env is prod. + DefaultJWTSecret = "dev-only-change-me" +) + +func (cfg *Config) Production() bool { + return cfg != nil && cfg.Env == EnvProd +} + +func (cfg *Config) normalize() error { + if cfg == nil { + return fmt.Errorf("config is nil") + } + applyDefaults(cfg) + return cfg.validate() +} + +func applyDefaults(cfg *Config) { + if v := os.Getenv("SPARK_ENV"); v != "" { + cfg.Env = v + } + cfg.Env = parseEnv(cfg.Env) + if v := os.Getenv("SPARK_JWT_SECRET"); v != "" { + cfg.JWT.Secret = v + } + if v := os.Getenv("SPARK_REDIS_PASSWORD"); v != "" { + cfg.Redis.Password = v + } + if cfg.JWT.Issuer == "" { + cfg.JWT.Issuer = "spark-im" + } + if cfg.JWT.TTL <= 0 { + cfg.JWT.TTL = 7 * 24 * time.Hour + } + if cfg.JWT.RefreshTTL <= 0 { + cfg.JWT.RefreshTTL = 30 * 24 * time.Hour + } + if cfg.JWT.DenylistPrefix == "" { + cfg.JWT.DenylistPrefix = "denylist:" + } + if cfg.JWT.RefreshPrefix == "" { + cfg.JWT.RefreshPrefix = "refresh:" + } + if cfg.Redis.DeliverQueueKey == "" { + cfg.Redis.DeliverQueueKey = "deliver:queue" + } + if cfg.Redis.DeliverProcessingKey == "" { + cfg.Redis.DeliverProcessingKey = "deliver:processing" + } + if cfg.Redis.DeliverDonePrefix == "" { + cfg.Redis.DeliverDonePrefix = "deliver:done:" + } + if cfg.Deliver.Workers < 1 { + cfg.Deliver.Workers = 8 + } + if cfg.Deliver.TakeTimeout <= 0 { + cfg.Deliver.TakeTimeout = 5 * time.Second + } + if cfg.Deliver.DoneTTL <= 0 { + cfg.Deliver.DoneTTL = 10 * time.Minute + } + if cfg.RateLimit.AuthPerSecond < 1 { + cfg.RateLimit.AuthPerSecond = 5 + } + if cfg.RateLimit.MessagePerSecond < 1 { + cfg.RateLimit.MessagePerSecond = 20 + } + if v := os.Getenv("SPARK_S3_ENDPOINT"); v != "" { + cfg.ObjectStorage.Endpoint = v + } + if v := os.Getenv("SPARK_S3_ACCESS_KEY"); v != "" { + cfg.ObjectStorage.AccessKey = v + } + if v := os.Getenv("SPARK_S3_SECRET_KEY"); v != "" { + cfg.ObjectStorage.SecretKey = v + } + if v := os.Getenv("SPARK_S3_BUCKET"); v != "" { + cfg.ObjectStorage.Bucket = v + } + if cfg.ObjectStorage.Bucket == "" { + cfg.ObjectStorage.Bucket = "spark" + } + if cfg.ObjectStorage.Region == "" { + cfg.ObjectStorage.Region = "us-east-1" + } + if cfg.ObjectStorage.PresignTTL <= 0 { + cfg.ObjectStorage.PresignTTL = 15 * time.Minute + } + if cfg.ObjectStorage.ChatExpireDays < 1 { + cfg.ObjectStorage.ChatExpireDays = 365 + } + if cfg.RateLimit.UploadPerSecond < 1 { + cfg.RateLimit.UploadPerSecond = 10 + } +} + +func parseEnv(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "prod", "production": + return EnvProd + default: + return EnvDev + } +} + +func (cfg *Config) validate() error { + if !cfg.Production() { + return nil + } + if strings.TrimSpace(cfg.MySQL.DSN) == "" { + return fmt.Errorf("prod: mysql.dsn is required") + } + if strings.TrimSpace(cfg.Redis.Addr) == "" { + return fmt.Errorf("prod: redis.addr is required") + } + if strings.TrimSpace(cfg.JWT.Secret) == "" || strings.TrimSpace(cfg.JWT.Secret) == DefaultJWTSecret { + return fmt.Errorf("prod: jwt.secret must be set via config or SPARK_JWT_SECRET") + } + if ep := strings.TrimSpace(cfg.ObjectStorage.Endpoint); ep != "" { + if strings.TrimSpace(cfg.ObjectStorage.AccessKey) == "" || strings.TrimSpace(cfg.ObjectStorage.SecretKey) == "" { + return fmt.Errorf("prod: object_storage access_key and secret_key are required") + } + } + return nil +} diff --git a/internal/bootstrap/health.go b/internal/bootstrap/health.go new file mode 100644 index 0000000..f7f7128 --- /dev/null +++ b/internal/bootstrap/health.go @@ -0,0 +1,95 @@ +package bootstrap + +import ( + "context" + "sync/atomic" + "time" + + "spark-server/internal/infrastructure/mysql" + redisx "spark-server/internal/infrastructure/redis" + tcp "spark-server/internal/transport/tcp" +) + +const ( + depOK = "ok" + depSkipped = "skipped" + depDown = "down" + depClosed = "closed" +) + +// ProbeReport is /readyz data. LB should use HTTP 503 when Ready is false. +type ProbeReport struct { + Status string `json:"status"` + Accepting bool `json:"accepting"` + MySQL string `json:"mysql"` + Redis string `json:"redis"` + ObjectStorage string `json:"object_storage"` + TaskPool string `json:"taskpool"` +} + +// Probe implements httpx.Health for liveness/readiness. +type Probe struct { + mysql *mysql.DB + redis *redisx.Client + objects pinger + pool tcp.TaskPool + accepting *atomic.Bool + requireDeps bool +} + +func (p *Probe) Ready(ctx context.Context) (bool, any) { + if p == nil { + return true, ProbeReport{Status: depOK, Accepting: true, MySQL: depSkipped, Redis: depSkipped, ObjectStorage: depSkipped, TaskPool: depOK} + } + ctx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + rep := ProbeReport{ + Accepting: p.accepting != nil && p.accepting.Load(), + MySQL: p.checkStore(ctx, p.mysql), + Redis: p.checkStore(ctx, p.redis), + ObjectStorage: p.checkOptional(ctx, p.objects), + TaskPool: depOK, + } + ready := rep.Accepting + if p.pool != nil && p.pool.Closed() { + rep.TaskPool = depClosed + ready = false + } + if rep.MySQL == depDown || rep.Redis == depDown || rep.ObjectStorage == depDown { + ready = false + } + if ready { + rep.Status = depOK + } else { + rep.Status = "not_ready" + } + return ready, rep +} + +type pinger interface { + Ready() bool + Ping(ctx context.Context) error +} + +func (p *Probe) checkStore(ctx context.Context, s pinger) string { + if s == nil || !s.Ready() { + if p.requireDeps { + return depDown + } + return depSkipped + } + if err := s.Ping(ctx); err != nil { + return depDown + } + return depOK +} + +func (p *Probe) checkOptional(ctx context.Context, s pinger) string { + if s == nil || !s.Ready() { + return depSkipped + } + if err := s.Ping(ctx); err != nil { + return depDown + } + return depOK +} diff --git a/internal/bootstrap/health_test.go b/internal/bootstrap/health_test.go new file mode 100644 index 0000000..742c011 --- /dev/null +++ b/internal/bootstrap/health_test.go @@ -0,0 +1,85 @@ +package bootstrap + +import ( + "sync/atomic" + "testing" +) + +func TestParseEnvAndProductionValidate(t *testing.T) { + t.Setenv("SPARK_ENV", "") + t.Setenv("SPARK_JWT_SECRET", "") + if parseEnv("") != EnvDev || parseEnv("production") != EnvProd || parseEnv("PROD") != EnvProd { + t.Fatal("parseEnv") + } + dev := &Config{Env: "dev", JWT: JWTConfig{Secret: DefaultJWTSecret}} + if err := dev.normalize(); err != nil { + t.Fatal(err) + } + if dev.Production() { + t.Fatal("dev") + } + prod := &Config{Env: "prod"} + if err := prod.normalize(); err == nil { + t.Fatal("prod missing stores") + } + prod = &Config{ + Env: "prod", + MySQL: MySQLConfig{DSN: "user:pass@tcp(127.0.0.1:3306)/im"}, + Redis: RedisConfig{Addr: "127.0.0.1:6379"}, + JWT: JWTConfig{Secret: DefaultJWTSecret}, + } + if err := prod.normalize(); err == nil { + t.Fatal("prod default jwt") + } + prod.JWT.Secret = "long-enough-production-secret" + if err := prod.normalize(); err != nil { + t.Fatal(err) + } + if !prod.Production() { + t.Fatal("prod flag") + } +} + +func TestRedisPasswordFromEnv(t *testing.T) { + t.Setenv("SPARK_REDIS_PASSWORD", "from-env") + cfg := &Config{Redis: RedisConfig{Addr: "127.0.0.1:6379", Password: "from-yaml"}} + if err := cfg.normalize(); err != nil { + t.Fatal(err) + } + if cfg.Redis.Password != "from-env" { + t.Fatalf("password %q", cfg.Redis.Password) + } +} + +func TestProbeReadyDevSkipsStores(t *testing.T) { + p := &Probe{requireDeps: false} + ok, data := p.Ready(t.Context()) + if ok { + t.Fatal("not accepting") + } + rep := data.(ProbeReport) + if rep.MySQL != depSkipped || rep.Redis != depSkipped || rep.Status != "not_ready" { + t.Fatalf("%+v", rep) + } + var accepting atomic.Bool + accepting.Store(true) + p.accepting = &accepting + ok, data = p.Ready(t.Context()) + if !ok { + t.Fatalf("%+v", data) + } +} + +func TestProbeReadyProdRequiresStores(t *testing.T) { + var accepting atomic.Bool + accepting.Store(true) + p := &Probe{requireDeps: true, accepting: &accepting} + ok, data := p.Ready(t.Context()) + if ok { + t.Fatal("want down") + } + rep := data.(ProbeReport) + if rep.MySQL != depDown || rep.Redis != depDown { + t.Fatalf("%+v", rep) + } +} diff --git a/internal/infrastructure/id/id.go b/internal/infrastructure/id/id.go index b229669..62003e8 100644 --- a/internal/infrastructure/id/id.go +++ b/internal/infrastructure/id/id.go @@ -27,6 +27,11 @@ func New(workerID int64) (*Generator, error) { } func (g *Generator) NextMsgID() (int64, error) { + return g.NextID() +} + +// NextID returns a time-ordered Snowflake ID (user id, conversation id, msg id, mailbox event id). +func (g *Generator) NextID() (int64, error) { g.mu.Lock() defer g.mu.Unlock() now := time.Now().UnixMilli() diff --git a/internal/infrastructure/logger/body.go b/internal/infrastructure/logger/body.go new file mode 100644 index 0000000..988805f --- /dev/null +++ b/internal/infrastructure/logger/body.go @@ -0,0 +1,20 @@ +package logger + +import ( + "regexp" +) + +const MaxBody = 4096 + +var secretJSON = regexp.MustCompile(`(?i)("(token|access_token|refresh_token|authorization|password|verify_code|upload_url|download_url|secret_key|access_key)"\s*:\s*")[^"]*`) + +func ClipBody(b []byte) string { + if len(b) == 0 { + return "" + } + s := secretJSON.ReplaceAllString(string(b), `${1}***`) + if len(s) > MaxBody { + return s[:MaxBody] + "..." + } + return s +} diff --git a/internal/infrastructure/logger/logger.go b/internal/infrastructure/logger/logger.go index 4428a03..cce1ceb 100644 --- a/internal/infrastructure/logger/logger.go +++ b/internal/infrastructure/logger/logger.go @@ -1,10 +1,67 @@ package logger import ( - "log/slog" - "os" + "context" + + "go.uber.org/zap" + + "spark-server/internal/pkg/reqctx" ) -func New() *slog.Logger { - return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) +type ctxKey struct{} + +func Nop() *zap.Logger { return zap.NewNop() } + +func With(ctx context.Context, log *zap.Logger) context.Context { + return context.WithValue(ctx, ctxKey{}, log) +} + +func From(ctx context.Context) *zap.Logger { + if ctx == nil { + return zap.NewNop() + } + if log, ok := ctx.Value(ctxKey{}).(*zap.Logger); ok && log != nil { + return log + } + return zap.NewNop() +} + +// Attach stores a logger on ctx with trace_id / request_id from RequestContext. +func Attach(ctx context.Context, base *zap.Logger) context.Context { + if base == nil { + base = zap.NewNop() + } + return With(ctx, WithReq(base, reqctx.From(ctx))) +} + +func WithReq(log *zap.Logger, rc *reqctx.RequestContext) *zap.Logger { + if log == nil { + log = zap.NewNop() + } + if rc == nil { + return log + } + fields := make([]zap.Field, 0, 8) + if rc.TraceID != "" { + fields = append(fields, zap.String("trace_id", rc.TraceID)) + } + if rc.RequestID != "" { + fields = append(fields, zap.String("request_id", rc.RequestID)) + } + if rc.ConnectionID != "" { + fields = append(fields, zap.String("connection_id", rc.ConnectionID)) + } + if rc.RealIP != "" { + fields = append(fields, zap.String("real_ip", rc.RealIP)) + } + if rc.Language != "" { + fields = append(fields, zap.String("lang", rc.Language)) + } + if rc.UserID != 0 { + fields = append(fields, zap.Int64("user_id", rc.UserID)) + } + if len(fields) == 0 { + return log + } + return log.With(fields...) } diff --git a/internal/infrastructure/logger/logger_test.go b/internal/infrastructure/logger/logger_test.go new file mode 100644 index 0000000..2e366f1 --- /dev/null +++ b/internal/infrastructure/logger/logger_test.go @@ -0,0 +1,43 @@ +package logger + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "go.uber.org/zap/zapcore" +) + +func TestNewWritesRotatingFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "app.log") + log, closeFn := New(&Options{ + Level: "info", + Console: false, + File: FileOptions{Path: path, MaxSizeMB: 1, MaxBackups: 1, MaxAgeDays: 1}, + }) + log.Info("hello_file") + if err := closeFn(); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "hello_file") { + t.Fatalf("file content %s", raw) + } +} + +func TestParseLevel(t *testing.T) { + if parseLevel("debug") != zapcore.DebugLevel || parseLevel("DEBUG") != zapcore.DebugLevel { + t.Fatal("debug") + } + if parseLevel("error") != zapcore.ErrorLevel { + t.Fatal("error") + } + if parseLevel("info") != zapcore.InfoLevel { + t.Fatal("info") + } +} diff --git a/internal/infrastructure/logger/options.go b/internal/infrastructure/logger/options.go new file mode 100644 index 0000000..d7de1a3 --- /dev/null +++ b/internal/infrastructure/logger/options.go @@ -0,0 +1,137 @@ +package logger + +import ( + "os" + "strings" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "gopkg.in/natefinch/lumberjack.v2" +) + +// Options control zap sinks. File uses lumberjack rotation; Console is for local debug. +type Options struct { + Level string + Encoding string // json | console (stdout only; files are always JSON) + Console bool + File FileOptions +} + +// FileOptions is lumberjack rotation for a JSON log file. +type FileOptions struct { + Path string + MaxSizeMB int + MaxBackups int + MaxAgeDays int + Compress bool +} + +type closer struct { + log *zap.Logger + lj *lumberjack.Logger +} + +func (c *closer) Close() error { + if c == nil { + return nil + } + if c.log != nil { + _ = c.log.Sync() + } + if c.lj != nil { + return c.lj.Close() + } + return nil +} + +// New builds a zap logger. Close the returned func on shutdown to flush and close the file sink. +func New(o *Options) (*zap.Logger, func() error) { + if o == nil { + o = &Options{} + } + applyDefaults(o) + level := parseLevel(o.Level) + var lj *lumberjack.Logger + cores := make([]zapcore.Core, 0, 2) + if o.Console { + cores = append(cores, zapcore.NewCore(stdoutEncoder(o.Encoding), zapcore.AddSync(os.Stdout), level)) + } + if o.File.Path != "" { + lj = newLumberjack(o.File) + cores = append(cores, zapcore.NewCore(jsonEncoder(), zapcore.AddSync(lj), level)) + } + if len(cores) == 0 { + cores = append(cores, zapcore.NewCore(jsonEncoder(), zapcore.AddSync(os.Stdout), level)) + } + log := zap.New(zapcore.NewTee(cores...), zap.AddCaller()) + c := &closer{log: log, lj: lj} + return log, c.Close +} + +func applyDefaults(o *Options) { + if o.Level == "" { + o.Level = "info" + } + if o.Encoding == "" { + if strings.EqualFold(o.Level, "debug") { + o.Encoding = "console" + } else { + o.Encoding = "json" + } + } + if !o.Console && o.File.Path == "" { + o.Console = true + } + if o.File.Path != "" { + if o.File.MaxSizeMB <= 0 { + o.File.MaxSizeMB = 100 + } + if o.File.MaxBackups <= 0 { + o.File.MaxBackups = 7 + } + if o.File.MaxAgeDays <= 0 { + o.File.MaxAgeDays = 7 + } + } +} + +func parseLevel(s string) zapcore.Level { + switch strings.ToLower(s) { + case "debug": + return zapcore.DebugLevel + case "warn", "warning": + return zapcore.WarnLevel + case "error": + return zapcore.ErrorLevel + default: + return zapcore.InfoLevel + } +} + +func jsonEncoder() zapcore.Encoder { + cfg := zap.NewProductionEncoderConfig() + cfg.TimeKey = "ts" + cfg.EncodeTime = zapcore.ISO8601TimeEncoder + cfg.MessageKey = "msg" + return zapcore.NewJSONEncoder(cfg) +} + +func stdoutEncoder(encoding string) zapcore.Encoder { + if strings.EqualFold(encoding, "json") { + return jsonEncoder() + } + cfg := zap.NewDevelopmentEncoderConfig() + cfg.EncodeTime = zapcore.ISO8601TimeEncoder + return zapcore.NewConsoleEncoder(cfg) +} + +func newLumberjack(f FileOptions) *lumberjack.Logger { + return &lumberjack.Logger{ + Filename: f.Path, + MaxSize: f.MaxSizeMB, + MaxBackups: f.MaxBackups, + MaxAge: f.MaxAgeDays, + Compress: f.Compress, + LocalTime: true, + } +} diff --git a/internal/infrastructure/mysql/gorm_logger.go b/internal/infrastructure/mysql/gorm_logger.go new file mode 100644 index 0000000..520be9f --- /dev/null +++ b/internal/infrastructure/mysql/gorm_logger.go @@ -0,0 +1,102 @@ +package mysql + +import ( + "context" + "errors" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" + + "spark-server/internal/infrastructure/logger" + "spark-server/internal/pkg/reqctx" +) + +type gormZap struct { + base *zap.Logger + level gormlogger.LogLevel + slow time.Duration +} + +func newGormLogger(base *zap.Logger, slow time.Duration, level gormlogger.LogLevel) *gormZap { + if base == nil { + base = zap.NewNop() + } + if slow <= 0 { + slow = 200 * time.Millisecond + } + if level == 0 { + level = gormlogger.Warn + } + return &gormZap{base: base.Named("gorm"), level: level, slow: slow} +} + +func (l *gormZap) LogMode(level gormlogger.LogLevel) gormlogger.Interface { + cp := *l + cp.level = level + return &cp +} + +func (l *gormZap) Info(ctx context.Context, msg string, data ...interface{}) { + if l.level < gormlogger.Info { + return + } + l.zap(ctx).Sugar().Infof(msg, data...) +} + +func (l *gormZap) Warn(ctx context.Context, msg string, data ...interface{}) { + if l.level < gormlogger.Warn { + return + } + l.zap(ctx).Sugar().Warnf(msg, data...) +} + +func (l *gormZap) Error(ctx context.Context, msg string, data ...interface{}) { + if l.level < gormlogger.Error { + return + } + l.zap(ctx).Sugar().Errorf(msg, data...) +} + +func (l *gormZap) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) { + if l.level <= gormlogger.Silent { + return + } + elapsed := time.Since(begin) + sql, rows := fc() + log := l.zap(ctx) + switch { + case err != nil && !errors.Is(err, gorm.ErrRecordNotFound) && l.level >= gormlogger.Error: + log.Error("sql", zap.Error(err), zap.String("sql", sql), zap.Duration("elapsed", elapsed), zap.Int64("rows", rows)) + case elapsed >= l.slow && l.level >= gormlogger.Warn: + log.Warn("sql_slow", zap.String("sql", sql), zap.Duration("elapsed", elapsed), zap.Int64("rows", rows)) + case l.level >= gormlogger.Info: + log.Info("sql", zap.String("sql", sql), zap.Duration("elapsed", elapsed), zap.Int64("rows", rows)) + } +} + +func (l *gormZap) zap(ctx context.Context) *zap.Logger { + if reqctx.From(ctx) != nil { + return logger.From(ctx).Named("gorm") + } + return l.base +} + +func parseGormLevel(s, zapLevel string) gormlogger.LogLevel { + switch s { + case "silent": + return gormlogger.Silent + case "error": + return gormlogger.Error + case "warn", "warning": + return gormlogger.Warn + case "info": + return gormlogger.Info + default: + if zapLevel == "debug" { + return gormlogger.Info + } + return gormlogger.Warn + } +} diff --git a/internal/infrastructure/mysql/gorm_logger_test.go b/internal/infrastructure/mysql/gorm_logger_test.go new file mode 100644 index 0000000..bf60b86 --- /dev/null +++ b/internal/infrastructure/mysql/gorm_logger_test.go @@ -0,0 +1,60 @@ +package mysql + +import ( + "context" + "errors" + "testing" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +func TestGormLoggerTrace(t *testing.T) { + core, logs := observer.New(zapcore.DebugLevel) + z := zap.New(core) + gl := newGormLogger(z, time.Millisecond, gormlogger.Info) + gl.Trace(context.Background(), time.Now().Add(-10*time.Millisecond), func() (string, int64) { + return "SELECT 1", 1 + }, nil) + if logs.FilterMessage("sql_slow").Len() != 1 { + t.Fatalf("want sql_slow, got %+v", logs.All()) + } + gl.Trace(context.Background(), time.Now(), func() (string, int64) { + return "SELECT 2", 1 + }, nil) + if logs.FilterMessage("sql").Len() < 1 { + t.Fatalf("want sql, got %+v", logs.All()) + } + gl.Trace(context.Background(), time.Now(), func() (string, int64) { + return "SELECT 3", 0 + }, errors.New("boom")) + if logs.FilterMessage("sql").FilterLevelExact(zapcore.ErrorLevel).Len() < 1 { + t.Fatalf("want sql error, got %+v", logs.All()) + } + gl.Trace(context.Background(), time.Now(), func() (string, int64) { + return "SELECT 4", 0 + }, gorm.ErrRecordNotFound) +} + +func TestParseGormLevel(t *testing.T) { + if parseGormLevel("", "debug") != gormlogger.Info { + t.Fatal("debug follows zap") + } + if parseGormLevel("", "info") != gormlogger.Warn { + t.Fatal("info default warn") + } + if parseGormLevel("silent", "debug") != gormlogger.Silent { + t.Fatal("silent") + } +} + +func TestOpenEmptyDSN(t *testing.T) { + db, err := Open(zap.NewNop(), &Options{}) + if err != nil || db.Ready() { + t.Fatalf("empty dsn %v ready=%v", err, db.Ready()) + } +} diff --git a/internal/infrastructure/mysql/mysql.go b/internal/infrastructure/mysql/mysql.go index 01b0794..dcb7a84 100644 --- a/internal/infrastructure/mysql/mysql.go +++ b/internal/infrastructure/mysql/mysql.go @@ -1,45 +1,128 @@ +// Package mysql opens GORM and writes SQL logs through zap. package mysql import ( "context" - "database/sql" "fmt" + "strings" "time" - // registers the MySQL driver with database/sql - _ "github.com/go-sql-driver/mysql" + "go.uber.org/zap" + mysqldriver "gorm.io/driver/mysql" + "gorm.io/gorm" ) +// DB is a GORM handle. Connection pool is the standard library pool behind GORM v2. type DB struct { - sql *sql.DB + gdb *gorm.DB } -func Open(dsn string) (*DB, error) { - if dsn == "" { +// Options configure Open, including the underlying sql.DB pool. +type Options struct { + DSN string + SlowThreshold time.Duration + LogLevel string + ZapLevel string + MaxOpenConns int + MaxIdleConns int + ConnMaxLifetime time.Duration + ConnMaxIdleTime time.Duration +} + +const ( + defaultMaxOpen = 64 + defaultMaxIdle = 16 + defaultConnLife = 30 * time.Minute + defaultConnIdle = 10 * time.Minute + defaultPingTimeout = 10 * time.Second +) + +// Open connects to MySQL. An empty DSN skips the connection for local bring-up. +func Open(log *zap.Logger, o *Options) (*DB, error) { + if o == nil { + o = &Options{} + } + if o.DSN == "" { return &DB{}, nil } - sqlDB, err := sql.Open("mysql", dsn) + applyPoolDefaults(o) + gdb, err := gorm.Open(mysqldriver.Open(withDialTimeout(o.DSN)), &gorm.Config{ + Logger: newGormLogger(log, o.SlowThreshold, parseGormLevel(o.LogLevel, o.ZapLevel)), + }) if err != nil { return nil, fmt.Errorf("mysql open: %w", err) } - sqlDB.SetMaxOpenConns(64) - sqlDB.SetConnMaxLifetime(30 * time.Minute) - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + sqlDB, err := gdb.DB() + if err != nil { + return nil, fmt.Errorf("mysql pool: %w", err) + } + sqlDB.SetMaxOpenConns(o.MaxOpenConns) + sqlDB.SetMaxIdleConns(o.MaxIdleConns) + sqlDB.SetConnMaxLifetime(o.ConnMaxLifetime) + sqlDB.SetConnMaxIdleTime(o.ConnMaxIdleTime) + ctx, cancel := context.WithTimeout(context.Background(), defaultPingTimeout) defer cancel() if err := sqlDB.PingContext(ctx); err != nil { _ = sqlDB.Close() return nil, fmt.Errorf("mysql ping: %w", err) } - return &DB{sql: sqlDB}, nil + return &DB{gdb: gdb}, nil } -func (d *DB) SQL() *sql.DB { return d.sql } +func applyPoolDefaults(o *Options) { + if o.MaxOpenConns <= 0 { + o.MaxOpenConns = defaultMaxOpen + } + if o.MaxIdleConns <= 0 { + o.MaxIdleConns = defaultMaxIdle + } + if o.ConnMaxLifetime <= 0 { + o.ConnMaxLifetime = defaultConnLife + } + if o.ConnMaxIdleTime <= 0 { + o.ConnMaxIdleTime = defaultConnIdle + } +} -func (d *DB) Close() error { - if d == nil || d.sql == nil { +func withDialTimeout(dsn string) string { + if strings.Contains(dsn, "timeout=") { + return dsn + } + sep := "?" + if strings.Contains(dsn, "?") { + sep = "&" + } + return dsn + sep + "timeout=10s" +} + +// Gorm returns the ORM handle (nil when DSN was empty). +func (d *DB) Gorm() *gorm.DB { + if d == nil { return nil } - return d.sql.Close() + return d.gdb } -func (d *DB) Ready() bool { return d != nil && d.sql != nil } +func (d *DB) Close() error { + if d == nil || d.gdb == nil { + return nil + } + sqlDB, err := d.gdb.DB() + if err != nil { + return err + } + return sqlDB.Close() +} + +func (d *DB) Ready() bool { return d != nil && d.gdb != nil } + +func (d *DB) Ping(ctx context.Context) error { + if !d.Ready() { + return nil + } + sqlDB, err := d.gdb.DB() + if err != nil { + return err + } + return sqlDB.PingContext(ctx) +} diff --git a/internal/infrastructure/objectstore/ops.go b/internal/infrastructure/objectstore/ops.go new file mode 100644 index 0000000..fc93ae6 --- /dev/null +++ b/internal/infrastructure/objectstore/ops.go @@ -0,0 +1,157 @@ +package objectstore + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/aws/smithy-go" + smithyhttp "github.com/aws/smithy-go/transport/http" + + "spark-server/internal/pkg/errs" +) + +const maxObjectBytes = 4 << 20 + +func (s *Store) PresignPut(ctx context.Context, key, contentType string, ttl time.Duration) (string, error) { + if !s.Ready() { + return "", errs.ErrAvailable + } + in := &s3.PutObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(key)} + if contentType != "" { + in.ContentType = aws.String(contentType) + } + out, err := s.presign.PresignPutObject(ctx, in, s3.WithPresignExpires(ttl)) + if err != nil { + return "", mapObjectErr(err) + } + return out.URL, nil +} + +func (s *Store) PresignGet(ctx context.Context, key string, ttl time.Duration) (string, error) { + if !s.Ready() { + return "", errs.ErrAvailable + } + out, err := s.presign.PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }, s3.WithPresignExpires(ttl)) + if err != nil { + return "", mapObjectErr(err) + } + return out.URL, nil +} + +func (s *Store) Get(ctx context.Context, key string) ([]byte, error) { + if !s.Ready() { + return nil, errs.ErrAvailable + } + out, err := s.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + return nil, mapObjectErr(err) + } + defer func() { _ = out.Body.Close() }() + data, err := io.ReadAll(io.LimitReader(out.Body, maxObjectBytes+1)) + if err != nil { + return nil, mapObjectErr(err) + } + if len(data) > maxObjectBytes { + return nil, errs.ErrInvalidArgument + } + return data, nil +} + +func (s *Store) Put(ctx context.Context, key string, body []byte, contentType string) error { + if !s.Ready() { + return errs.ErrAvailable + } + if len(body) == 0 || len(body) > maxObjectBytes { + return errs.ErrInvalidArgument + } + in := &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: bytes.NewReader(body), + ContentLength: aws.Int64(int64(len(body))), + } + if contentType != "" { + in.ContentType = aws.String(contentType) + } + if _, err := s.client.PutObject(ctx, in); err != nil { + return mapObjectErr(err) + } + return nil +} + +func (s *Store) Copy(ctx context.Context, src, dst string) error { + if !s.Ready() { + return errs.ErrAvailable + } + _, err := s.client.CopyObject(ctx, &s3.CopyObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(dst), + CopySource: aws.String(s.bucket + "/" + src), + }) + if err != nil { + return mapObjectErr(err) + } + return nil +} + +func (s *Store) Delete(ctx context.Context, key string) error { + if !s.Ready() { + return errs.ErrAvailable + } + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + return mapObjectErr(err) + } + return nil +} + +func mapObjectErr(err error) error { + if err == nil { + return nil + } + if app, ok := errs.AsAppError(err); ok { + return app + } + if isMissingObject(err) { + return errs.ErrMediaExpired.WithError("objectstore", err) + } + return errs.ErrOperatFail.WithError("objectstore", err) +} + +func isMissingObject(err error) bool { + var nf *types.NotFound + var nk *types.NoSuchKey + var nb *types.NoSuchBucket + if errors.As(err, &nf) || errors.As(err, &nk) || errors.As(err, &nb) { + return true + } + var api smithy.APIError + if errors.As(err, &api) { + switch strings.ToLower(api.ErrorCode()) { + case "nosuchkey", "nosuchobject", "notfound", "nosuchbucket": + return true + } + } + var httpErr *smithyhttp.ResponseError + if errors.As(err, &httpErr) && httpErr.HTTPStatusCode() == http.StatusNotFound { + return true + } + return false +} diff --git a/internal/infrastructure/objectstore/policy.go b/internal/infrastructure/objectstore/policy.go new file mode 100644 index 0000000..3bfae9b --- /dev/null +++ b/internal/infrastructure/objectstore/policy.go @@ -0,0 +1,85 @@ +package objectstore + +import ( + "context" + "errors" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "go.uber.org/zap" +) + +func (s *Store) ensureBucket(ctx context.Context, region string, chatExpireDays int) error { + _, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.bucket)}) + if err != nil { + if !isMissingObject(err) { + return fmt.Errorf("objectstore ping: %w", err) + } + if err := s.createBucket(ctx, region); err != nil { + return err + } + } + s.applyLifecycle(ctx, chatExpireDays) + s.applyCORS(ctx) + return nil +} + +func (s *Store) createBucket(ctx context.Context, region string) error { + in := &s3.CreateBucketInput{Bucket: aws.String(s.bucket)} + if region != "" && region != defaultRegion { + in.CreateBucketConfiguration = &types.CreateBucketConfiguration{ + LocationConstraint: types.BucketLocationConstraint(region), + } + } + if _, err := s.client.CreateBucket(ctx, in); err != nil { + var owned *types.BucketAlreadyOwnedByYou + var exists *types.BucketAlreadyExists + if errors.As(err, &owned) || errors.As(err, &exists) { + return nil + } + return fmt.Errorf("objectstore make bucket: %w", err) + } + return nil +} + +func (s *Store) applyLifecycle(ctx context.Context, days int) { + if days < 1 { + return + } + _, err := s.client.PutBucketLifecycleConfiguration(ctx, &s3.PutBucketLifecycleConfigurationInput{ + Bucket: aws.String(s.bucket), + LifecycleConfiguration: &types.BucketLifecycleConfiguration{ + Rules: []types.LifecycleRule{{ + ID: aws.String("expire-chat"), + Status: types.ExpirationStatusEnabled, + Filter: &types.LifecycleRuleFilter{Prefix: aws.String("chat/")}, + Expiration: &types.LifecycleExpiration{ + Days: aws.Int32(int32(days)), + }, + }}, + }, + }) + if err != nil { + s.log.Warn("objectstore lifecycle", zap.Error(err)) + } +} + +func (s *Store) applyCORS(ctx context.Context) { + _, err := s.client.PutBucketCors(ctx, &s3.PutBucketCorsInput{ + Bucket: aws.String(s.bucket), + CORSConfiguration: &types.CORSConfiguration{ + CORSRules: []types.CORSRule{{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{"GET", "PUT", "HEAD"}, + AllowedHeaders: []string{"*"}, + ExposeHeaders: []string{"ETag", "Content-Length"}, + MaxAgeSeconds: aws.Int32(3600), + }}, + }, + }) + if err != nil { + s.log.Warn("objectstore cors", zap.Error(err)) + } +} diff --git a/internal/infrastructure/objectstore/store.go b/internal/infrastructure/objectstore/store.go new file mode 100644 index 0000000..31310d7 --- /dev/null +++ b/internal/infrastructure/objectstore/store.go @@ -0,0 +1,109 @@ +// Package objectstore talks to S3-compatible object storage (RustFS) via AWS SDK v2. +package objectstore + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "go.uber.org/zap" +) + +const ( + defaultRegion = "us-east-1" + defaultTimeout = 15 * time.Second +) + +// Store is a RustFS / S3 client. Nil or !Ready means object storage is not configured. +type Store struct { + client *s3.Client + presign *s3.PresignClient + bucket string + log *zap.Logger +} + +// Options configure Open. Empty Endpoint skips the connection. +type Options struct { + Endpoint string + AccessKey string + SecretKey string + Bucket string + Region string + UseSSL bool + ChatExpireDays int + Log *zap.Logger +} + +func Open(o *Options) (*Store, error) { + if o == nil || strings.TrimSpace(o.Endpoint) == "" { + return &Store{}, nil + } + host, secure, err := parseEndpoint(o.Endpoint, o.UseSSL) + if err != nil { + return nil, err + } + bucket := strings.TrimSpace(o.Bucket) + if bucket == "" { + bucket = "spark" + } + region := strings.TrimSpace(o.Region) + if region == "" { + region = defaultRegion + } + scheme := "http" + if secure { + scheme = "https" + } + client := newS3Client(region, scheme+"://"+host, o.AccessKey, o.SecretKey) + s := &Store{client: client, presign: s3.NewPresignClient(client), bucket: bucket, log: o.Log} + if s.log == nil { + s.log = zap.NewNop() + } + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + if err := s.ensureBucket(ctx, region, o.ChatExpireDays); err != nil { + return nil, err + } + return s, nil +} + +func (s *Store) Ready() bool { + return s != nil && s.client != nil && s.presign != nil && s.bucket != "" +} + +func (s *Store) Ping(ctx context.Context) error { + if !s.Ready() { + return nil + } + _, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.bucket)}) + return err +} + +func newS3Client(region, baseURL, access, secret string) *s3.Client { + return s3.New(s3.Options{ + Region: region, + Credentials: credentials.NewStaticCredentialsProvider(access, secret, ""), + BaseEndpoint: aws.String(baseURL), + UsePathStyle: true, + RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired, + ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired, + }) +} + +func parseEndpoint(raw string, useSSL bool) (host string, secure bool, err error) { + raw = strings.TrimSpace(raw) + switch { + case raw == "": + return "", false, fmt.Errorf("objectstore: empty endpoint") + case strings.HasPrefix(raw, "https://"): + return strings.TrimPrefix(raw, "https://"), true, nil + case strings.HasPrefix(raw, "http://"): + return strings.TrimPrefix(raw, "http://"), false, nil + default: + return raw, useSSL, nil + } +} diff --git a/internal/infrastructure/objectstore/store_test.go b/internal/infrastructure/objectstore/store_test.go new file mode 100644 index 0000000..0adbc78 --- /dev/null +++ b/internal/infrastructure/objectstore/store_test.go @@ -0,0 +1,104 @@ +package objectstore + +import ( + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "go.uber.org/zap" + + "spark-server/internal/pkg/errs" +) + +func TestParseEndpoint(t *testing.T) { + host, secure, err := parseEndpoint("http://47.236.66.9:9000", true) + if err != nil || host != "47.236.66.9:9000" || secure { + t.Fatalf("%s %v %v", host, secure, err) + } + host, secure, err = parseEndpoint("47.236.66.9:9000", false) + if err != nil || host != "47.236.66.9:9000" || secure { + t.Fatalf("plain %s %v %v", host, secure, err) + } + host, secure, err = parseEndpoint("https://fs.example.com", false) + if err != nil || host != "fs.example.com" || !secure { + t.Fatalf("https %s %v %v", host, secure, err) + } +} + +func TestOpenEmpty(t *testing.T) { + s, err := Open(&Options{}) + if err != nil || s.Ready() { + t.Fatalf("ready=%v err=%v", s.Ready(), err) + } +} + +func TestPresignLocal(t *testing.T) { + client := newS3Client(defaultRegion, "http://127.0.0.1:9000", "ak", "sk") + s := &Store{client: client, presign: s3.NewPresignClient(client), bucket: "spark"} + u, err := s.PresignPut(t.Context(), "chat/a.jpg", "image/jpeg", time.Hour) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(u, "127.0.0.1:9000/spark/chat/a.jpg") { + t.Fatalf("%s", u) + } + g, err := s.PresignGet(t.Context(), "perm/avatar/1.jpg", time.Minute) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(g, "perm/avatar/1.jpg") { + t.Fatalf("%s", g) + } +} + +func TestGetPutNotReady(t *testing.T) { + s, err := Open(&Options{}) + if err != nil { + t.Fatal(err) + } + if _, err := s.Get(t.Context(), "perm/a"); !errors.Is(err, errs.ErrAvailable) { + t.Fatalf("get %v", err) + } + if err := s.Put(t.Context(), "perm/a", []byte("x"), "image/jpeg"); !errors.Is(err, errs.ErrAvailable) { + t.Fatalf("put %v", err) + } +} + +func TestMapObjectErr(t *testing.T) { + err := mapObjectErr(&types.NoSuchKey{Message: aws.String("gone")}) + if !errors.Is(err, errs.ErrMediaExpired) { + t.Fatalf("%v", err) + } + err = mapObjectErr(errors.New("boom")) + if !errors.Is(err, errs.ErrOperatFail) { + t.Fatalf("%v", err) + } +} + +func TestOpenLive(t *testing.T) { + if os.Getenv("SPARK_S3_LIVE") == "" { + t.Skip("set SPARK_S3_LIVE=1 to hit RustFS") + } + s, err := Open(&Options{ + Endpoint: "47.236.66.9:9000", + AccessKey: os.Getenv("SPARK_S3_ACCESS_KEY"), + SecretKey: os.Getenv("SPARK_S3_SECRET_KEY"), + Bucket: "spark", + ChatExpireDays: 365, + Log: zap.NewNop(), + }) + if err != nil { + t.Fatal(err) + } + if !s.Ready() { + t.Fatal("not ready") + } + if err := s.Ping(t.Context()); err != nil { + t.Fatal(err) + } +} diff --git a/internal/infrastructure/push/push.go b/internal/infrastructure/push/push.go index 86c2ae1..0ff2d1c 100644 --- a/internal/infrastructure/push/push.go +++ b/internal/infrastructure/push/push.go @@ -3,10 +3,12 @@ package push import "context" // Notification is a system push. Mute is decided in Deliver Worker, not gnet. +// Title/Body must be localized with i18n.Notify using the recipient device language. type Notification struct { UserID int64 DeviceID uint64 ConversationID int64 + Type string Title string Body string Badge int diff --git a/internal/infrastructure/redis/redis.go b/internal/infrastructure/redis/redis.go index e69d067..3d575bd 100644 --- a/internal/infrastructure/redis/redis.go +++ b/internal/infrastructure/redis/redis.go @@ -12,12 +12,28 @@ type Client struct { rdb *redis.Client } -func Open(addr string) (*Client, error) { - if addr == "" { +// Options configure Open. Empty Addr skips the connection for local bring-up. +type Options struct { + Addr string + Username string + Password string + DB int +} + +func Open(o *Options) (*Client, error) { + if o == nil || o.Addr == "" { return &Client{}, nil } - rdb := redis.NewClient(&redis.Options{Addr: addr}) - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + rdb := redis.NewClient(&redis.Options{ + Addr: o.Addr, + Username: o.Username, + Password: o.Password, + DB: o.DB, + DialTimeout: 10 * time.Second, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := rdb.Ping(ctx).Err(); err != nil { _ = rdb.Close() @@ -36,3 +52,10 @@ func (c *Client) Close() error { } func (c *Client) Ready() bool { return c != nil && c.rdb != nil } + +func (c *Client) Ping(ctx context.Context) error { + if !c.Ready() { + return nil + } + return c.rdb.Ping(ctx).Err() +} diff --git a/internal/infrastructure/redis/redis_test.go b/internal/infrastructure/redis/redis_test.go new file mode 100644 index 0000000..bb56df0 --- /dev/null +++ b/internal/infrastructure/redis/redis_test.go @@ -0,0 +1,14 @@ +package redisx + +import "testing" + +func TestOpenEmptyAddrSkips(t *testing.T) { + c, err := Open(nil) + if err != nil || c.Ready() { + t.Fatalf("nil options: %v ready=%v", err, c.Ready()) + } + c, err = Open(&Options{}) + if err != nil || c.Ready() { + t.Fatalf("empty addr: %v ready=%v", err, c.Ready()) + } +} diff --git a/internal/module/auth/entity/principal.go b/internal/module/auth/entity/principal.go new file mode 100644 index 0000000..ac0dc7c --- /dev/null +++ b/internal/module/auth/entity/principal.go @@ -0,0 +1,23 @@ +// Package entity holds auth domain types shared by HTTP Bearer and TCP AUTH. +package entity + +import "time" + +// Principal is the authenticated identity. +type Principal struct { + UserID int64 + DeviceID uint64 + JTI string + ExpiresAt time.Time +} + +// IssuedToken is the login / refresh `data` payload. +type IssuedToken struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` + RefreshExpiresIn int64 `json:"refresh_expires_in"` + UserID int64 `json:"user_id"` + DeviceID uint64 `json:"device_id"` + SparkID string `json:"spark_id"` +} diff --git a/internal/module/auth/logic/denylist.go b/internal/module/auth/logic/denylist.go new file mode 100644 index 0000000..a751ede --- /dev/null +++ b/internal/module/auth/logic/denylist.go @@ -0,0 +1,91 @@ +package logic + +import ( + "context" + "sync" + "time" + + "github.com/redis/go-redis/v9" + + "spark-server/internal/pkg/errs" +) + +// Denylist stores revoked JWT ids until their original expiry. +type Denylist interface { + Has(ctx context.Context, jti string) (bool, error) + Add(ctx context.Context, jti string, ttl time.Duration) error +} + +// MemoryDenylist is process-local. Used in tests and when Redis is not configured. +type MemoryDenylist struct { + mu sync.Mutex + items map[string]int64 // jti → expiry unix nano +} + +func NewMemoryDenylist() *MemoryDenylist { + return &MemoryDenylist{items: make(map[string]int64)} +} + +func (d *MemoryDenylist) Has(_ context.Context, jti string) (bool, error) { + if jti == "" { + return false, nil + } + now := time.Now().UnixNano() + d.mu.Lock() + defer d.mu.Unlock() + exp, ok := d.items[jti] + if !ok { + return false, nil + } + if now >= exp { + delete(d.items, jti) + return false, nil + } + return true, nil +} + +func (d *MemoryDenylist) Add(_ context.Context, jti string, ttl time.Duration) error { + if jti == "" || ttl <= 0 { + return nil + } + d.mu.Lock() + d.items[jti] = time.Now().Add(ttl).UnixNano() + d.mu.Unlock() + return nil +} + +// RedisDenylist uses SETEX denylist:{jti} with remaining token TTL. +type RedisDenylist struct { + rdb *redis.Client + prefix string +} + +func NewRedisDenylist(rdb *redis.Client, prefix string) *RedisDenylist { + if prefix == "" { + prefix = "denylist:" + } + return &RedisDenylist{rdb: rdb, prefix: prefix} +} + +func (d *RedisDenylist) key(jti string) string { return d.prefix + jti } + +func (d *RedisDenylist) Has(ctx context.Context, jti string) (bool, error) { + if d == nil || d.rdb == nil { + return false, errs.ErrAvailable.WithInnerMsg("jwt denylist redis not ready") + } + n, err := d.rdb.Exists(ctx, d.key(jti)).Result() + if err != nil { + return false, err + } + return n > 0, nil +} + +func (d *RedisDenylist) Add(ctx context.Context, jti string, ttl time.Duration) error { + if d == nil || d.rdb == nil { + return errs.ErrAvailable.WithInnerMsg("jwt denylist redis not ready") + } + if jti == "" || ttl <= 0 { + return nil + } + return d.rdb.Set(ctx, d.key(jti), "1", ttl).Err() +} diff --git a/internal/module/auth/logic/jwt.go b/internal/module/auth/logic/jwt.go new file mode 100644 index 0000000..e8fd485 --- /dev/null +++ b/internal/module/auth/logic/jwt.go @@ -0,0 +1,308 @@ +// Package logic is the auth application use-case layer (JWT, login, denylist). +package logic + +import ( + "context" + "strconv" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + + authentity "spark-server/internal/module/auth/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" +) + +// Service validates an access token. HTTP middleware and TCP AUTH share this. +type Service interface { + Authenticate(ctx context.Context, token string) (*authentity.Principal, error) +} + +// Issuer mints, refreshes, and revokes access tokens. +type Issuer interface { + Issue(ctx context.Context, userID int64, deviceID uint64) (*authentity.IssuedToken, error) + Refresh(ctx context.Context, refreshToken string) (*authentity.IssuedToken, error) + Logout(ctx context.Context, userID int64, deviceID uint64, accessJTI string, accessExp time.Time) error + Revoke(ctx context.Context, token string) error +} + +// TokenService is JWT: authenticate, issue, and revoke. +type TokenService interface { + Service + Issuer +} + +const ( + defaultIssuer = "spark-im" + defaultTTL = 7 * 24 * time.Hour + defaultRefreshTTL = 30 * 24 * time.Hour +) + +// Options configure HS256 JWT issuance and validation. +type Options struct { + Secret string + Issuer string + TTL time.Duration + RefreshTTL time.Duration + Denylist Denylist + Pairs PairStore + Clock func() time.Time +} + +type claims struct { + DeviceID uint64 `json:"did"` + Use string `json:"use,omitempty"` + jwt.RegisteredClaims +} + +// JWT implements TokenService with HS256 and an optional jti denylist. +type JWT struct { + secret []byte + issuer string + ttl time.Duration + refreshTTL time.Duration + denylist Denylist + pairs PairStore + clock func() time.Time +} + +func NewJWT(opt *Options) (*JWT, error) { + if opt == nil || strings.TrimSpace(opt.Secret) == "" { + return nil, errs.ErrInner.WithInnerMsg("jwt: secret is required") + } + issuer := opt.Issuer + if issuer == "" { + issuer = defaultIssuer + } + ttl := opt.TTL + if ttl <= 0 { + ttl = defaultTTL + } + refreshTTL := opt.RefreshTTL + if refreshTTL <= 0 { + refreshTTL = defaultRefreshTTL + } + dl := opt.Denylist + if dl == nil { + dl = NewMemoryDenylist() + } + pairs := opt.Pairs + if pairs == nil { + pairs = NewMemoryPairs() + } + clock := opt.Clock + if clock == nil { + clock = time.Now + } + return &JWT{ + secret: []byte(opt.Secret), + issuer: issuer, + ttl: ttl, + refreshTTL: refreshTTL, + denylist: dl, + pairs: pairs, + clock: clock, + }, nil +} + +func (j *JWT) Issue(ctx context.Context, userID int64, deviceID uint64) (*authentity.IssuedToken, error) { + if err := ctx.Err(); err != nil { + return nil, errs.FromRPCError("jwt", err) + } + if userID <= 0 || deviceID == 0 { + return nil, errs.ErrInvalidArgument + } + access, err := j.sign(userID, deviceID, tokenUseAccess, j.ttl) + if err != nil { + return nil, err + } + refresh, err := j.sign(userID, deviceID, tokenUseRefresh, j.refreshTTL) + if err != nil { + return nil, err + } + if err := j.replacePair(ctx, userID, deviceID, access.jti, refresh.jti); err != nil { + return nil, err + } + return &authentity.IssuedToken{ + AccessToken: access.raw, + RefreshToken: refresh.raw, + ExpiresIn: int64(j.ttl / time.Second), + RefreshExpiresIn: int64(j.refreshTTL / time.Second), + UserID: userID, + DeviceID: deviceID, + }, nil +} + +type signedToken struct { + raw string + jti string +} + +func (j *JWT) sign(userID int64, deviceID uint64, use string, ttl time.Duration) (signedToken, error) { + now := j.clock() + jti := reqctx.NewID() + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, &claims{ + DeviceID: deviceID, + Use: use, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: strconv.FormatInt(userID, 10), + Issuer: j.issuer, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + ID: jti, + }, + }) + raw, err := tok.SignedString(j.secret) + if err != nil { + return signedToken{}, errs.ErrInner.WithError("jwt", err) + } + return signedToken{raw: raw, jti: jti}, nil +} + +func (j *JWT) replacePair(ctx context.Context, userID int64, deviceID uint64, accessJTI, refreshJTI string) error { + old, err := j.pairs.Get(ctx, userID, deviceID) + if err != nil { + return errs.ErrAvailable.WithError("jwt-refresh", err) + } + if old.Access != "" { + _ = j.denylist.Add(ctx, old.Access, j.ttl) + } + if old.Refresh != "" { + _ = j.denylist.Add(ctx, old.Refresh, j.refreshTTL) + } + if err := j.pairs.Put(ctx, userID, deviceID, TokenPair{Access: accessJTI, Refresh: refreshJTI}, j.refreshTTL); err != nil { + return errs.ErrAvailable.WithError("jwt-refresh", err) + } + return nil +} + +func (j *JWT) Refresh(ctx context.Context, refreshToken string) (*authentity.IssuedToken, error) { + c, err := j.parse(refreshToken, false) + if err != nil { + return nil, err + } + if c.Use != tokenUseRefresh { + return nil, errs.ErrUnauthorized + } + denied, err := j.denylist.Has(ctx, c.ID) + if err != nil { + return nil, errs.ErrAvailable.WithError("jwt-denylist", err) + } + if denied { + return nil, errs.ErrUnauthorized + } + uid, err := strconv.ParseInt(c.Subject, 10, 64) + if err != nil || uid <= 0 || c.DeviceID == 0 { + return nil, errs.ErrUnauthorized + } + stored, err := j.pairs.Get(ctx, uid, c.DeviceID) + if err != nil { + return nil, errs.ErrAvailable.WithError("jwt-refresh", err) + } + if stored.Refresh == "" || stored.Refresh != c.ID { + return nil, errs.ErrUnauthorized + } + return j.Issue(ctx, uid, c.DeviceID) +} + +func (j *JWT) Logout(ctx context.Context, userID int64, deviceID uint64, accessJTI string, accessExp time.Time) error { + if accessJTI != "" { + remain := accessExp.Sub(j.clock()) + if remain > 0 { + if err := j.denylist.Add(ctx, accessJTI, remain); err != nil { + return errs.ErrAvailable.WithError("jwt-denylist", err) + } + } + } + old, err := j.pairs.Get(ctx, userID, deviceID) + if err != nil { + return errs.ErrAvailable.WithError("jwt-refresh", err) + } + if old.Access != "" && old.Access != accessJTI { + _ = j.denylist.Add(ctx, old.Access, j.ttl) + } + if old.Refresh != "" { + _ = j.denylist.Add(ctx, old.Refresh, j.refreshTTL) + } + if err := j.pairs.Delete(ctx, userID, deviceID); err != nil { + return errs.ErrAvailable.WithError("jwt-refresh", err) + } + return nil +} + +func (j *JWT) Authenticate(ctx context.Context, token string) (*authentity.Principal, error) { + c, err := j.parse(token, false) + if err != nil { + return nil, err + } + denied, err := j.denylist.Has(ctx, c.ID) + if err != nil { + return nil, errs.ErrAvailable.WithError("jwt-denylist", err) + } + if denied { + return nil, errs.ErrUnauthorized + } + if c.Use != "" && c.Use != tokenUseAccess { + return nil, errs.ErrUnauthorized + } + uid, err := strconv.ParseInt(c.Subject, 10, 64) + if err != nil || uid <= 0 || c.DeviceID == 0 { + return nil, errs.ErrUnauthorized + } + exp := time.Time{} + if c.ExpiresAt != nil { + exp = c.ExpiresAt.Time + } + return &authentity.Principal{UserID: uid, DeviceID: c.DeviceID, JTI: c.ID, ExpiresAt: exp}, nil +} + +func (j *JWT) Revoke(ctx context.Context, token string) error { + c, err := j.parse(token, true) + if err != nil { + return err + } + if c.ExpiresAt == nil { + return nil + } + remain := c.ExpiresAt.Time.Sub(j.clock()) + if remain <= 0 { + return nil + } + if err := j.denylist.Add(ctx, c.ID, remain); err != nil { + return errs.ErrAvailable.WithError("jwt-denylist", err) + } + return nil +} + +func (j *JWT) parse(token string, allowExpired bool) (*claims, error) { + if token == "" { + return nil, errs.ErrUnauthorized + } + opts := []jwt.ParserOption{ + jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), + jwt.WithTimeFunc(j.clock), + } + if allowExpired { + opts = append(opts, jwt.WithoutClaimsValidation()) + } else { + opts = append(opts, jwt.WithIssuer(j.issuer), jwt.WithIssuedAt(), jwt.WithExpirationRequired()) + } + parsed, err := jwt.ParseWithClaims(token, &claims{}, func(t *jwt.Token) (any, error) { + if t.Method != jwt.SigningMethodHS256 { + return nil, jwt.ErrSignatureInvalid + } + return j.secret, nil + }, opts...) + if err != nil || parsed == nil || !parsed.Valid { + return nil, errs.ErrUnauthorized + } + c, ok := parsed.Claims.(*claims) + if !ok || c.ID == "" { + return nil, errs.ErrUnauthorized + } + if allowExpired && c.Issuer != j.issuer { + return nil, errs.ErrUnauthorized + } + return c, nil +} diff --git a/internal/module/auth/logic/jwt_test.go b/internal/module/auth/logic/jwt_test.go new file mode 100644 index 0000000..e4bf03b --- /dev/null +++ b/internal/module/auth/logic/jwt_test.go @@ -0,0 +1,209 @@ +package logic + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + + "spark-server/internal/pkg/errs" +) + +func testJWT(t *testing.T, clock func() time.Time) *JWT { + t.Helper() + j, err := NewJWT(&Options{ + Secret: "test-secret-test-secret", + Issuer: "spark-im", + TTL: time.Hour, + Clock: clock, + }) + if err != nil { + t.Fatal(err) + } + return j +} + +func TestJWTIssueAuthenticate(t *testing.T) { + ctx := context.Background() + j := testJWT(t, nil) + tok, err := j.Issue(ctx, 42, 7) + if err != nil { + t.Fatal(err) + } + if tok.UserID != 42 || tok.DeviceID != 7 || tok.ExpiresIn != 3600 || tok.AccessToken == "" || tok.RefreshToken == "" || tok.RefreshExpiresIn != 2592000 { + t.Fatalf("%+v", tok) + } + p, err := j.Authenticate(ctx, tok.AccessToken) + if err != nil { + t.Fatal(err) + } + if p.UserID != 42 || p.DeviceID != 7 || p.JTI == "" { + t.Fatalf("%+v", p) + } +} + +func TestJWTRejectsBadToken(t *testing.T) { + ctx := context.Background() + j := testJWT(t, nil) + if _, err := j.Authenticate(ctx, ""); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("empty: %v", err) + } + if _, err := j.Authenticate(ctx, "not-a-jwt"); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("garbage: %v", err) + } + other, err := NewJWT(&Options{Secret: "other-secret-other-secret", Issuer: "spark-im"}) + if err != nil { + t.Fatal(err) + } + tok, err := other.Issue(ctx, 1, 1) + if err != nil { + t.Fatal(err) + } + if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("wrong secret: %v", err) + } +} + +func TestJWTRejectsExpiredAndWrongIssuer(t *testing.T) { + ctx := context.Background() + now := time.Now() + clock := func() time.Time { return now } + j := testJWT(t, clock) + tok, err := j.Issue(ctx, 1, 2) + if err != nil { + t.Fatal(err) + } + j.clock = func() time.Time { return now.Add(2 * time.Hour) } + if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("expired: %v", err) + } + + wrongIss, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "other", TTL: time.Hour, Clock: clock}) + if err != nil { + t.Fatal(err) + } + issTok, err := wrongIss.Issue(ctx, 1, 2) + if err != nil { + t.Fatal(err) + } + if _, err := j.Authenticate(ctx, issTok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("issuer: %v", err) + } +} + +func TestJWTRefreshRotatesAndRejectsOld(t *testing.T) { + ctx := context.Background() + j := testJWT(t, nil) + tok, err := j.Issue(ctx, 5, 8) + if err != nil { + t.Fatal(err) + } + if _, err := j.Authenticate(ctx, tok.RefreshToken); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("refresh as access: %v", err) + } + next, err := j.Refresh(ctx, tok.RefreshToken) + if err != nil { + t.Fatal(err) + } + if next.AccessToken == tok.AccessToken || next.RefreshToken == tok.RefreshToken { + t.Fatal("expected new pair") + } + if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("old access: %v", err) + } + if _, err := j.Refresh(ctx, tok.RefreshToken); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("old refresh: %v", err) + } + if _, err := j.Authenticate(ctx, next.AccessToken); err != nil { + t.Fatal(err) + } +} + +func TestJWTLogoutRevokesPair(t *testing.T) { + ctx := context.Background() + j := testJWT(t, nil) + tok, err := j.Issue(ctx, 4, 2) + if err != nil { + t.Fatal(err) + } + p, err := j.Authenticate(ctx, tok.AccessToken) + if err != nil { + t.Fatal(err) + } + if err := j.Logout(ctx, p.UserID, p.DeviceID, p.JTI, p.ExpiresAt); err != nil { + t.Fatal(err) + } + if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("access after logout: %v", err) + } + if _, err := j.Refresh(ctx, tok.RefreshToken); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("refresh after logout: %v", err) + } +} + +func TestJWTDenylistRevoke(t *testing.T) { + ctx := context.Background() + j := testJWT(t, nil) + tok, err := j.Issue(ctx, 9, 3) + if err != nil { + t.Fatal(err) + } + if err := j.Revoke(ctx, tok.AccessToken); err != nil { + t.Fatal(err) + } + if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("revoked: %v", err) + } +} + +func TestJWTIssueRejectsBadIDs(t *testing.T) { + j := testJWT(t, nil) + if _, err := j.Issue(context.Background(), 0, 1); !errors.Is(err, errs.ErrInvalidArgument) { + t.Fatalf("user: %v", err) + } + if _, err := j.Issue(context.Background(), 1, 0); !errors.Is(err, errs.ErrInvalidArgument) { + t.Fatalf("device: %v", err) + } +} + +func TestJWTRejectsNoneAlg(t *testing.T) { + j := testJWT(t, nil) + tok := jwt.NewWithClaims(jwt.SigningMethodNone, &claims{ + DeviceID: 1, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "1", + Issuer: "spark-im", + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + ID: "jti-none", + }, + }) + raw, err := tok.SignedString(jwt.UnsafeAllowNoneSignatureType) + if err != nil { + t.Fatal(err) + } + if _, err := j.Authenticate(context.Background(), raw); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("none: %v", err) + } +} + +func TestNewJWTRequiresSecret(t *testing.T) { + if _, err := NewJWT(&Options{}); err == nil { + t.Fatal("expected secret error") + } +} + +func TestMemoryDenylistExpiry(t *testing.T) { + d := NewMemoryDenylist() + ctx := context.Background() + if err := d.Add(ctx, "x", time.Millisecond); err != nil { + t.Fatal(err) + } + time.Sleep(5 * time.Millisecond) + ok, err := d.Has(ctx, "x") + if err != nil || ok { + t.Fatalf("expired still present: %v %v", ok, err) + } +} diff --git a/internal/module/auth/logic/login.go b/internal/module/auth/logic/login.go new file mode 100644 index 0000000..d88c720 --- /dev/null +++ b/internal/module/auth/logic/login.go @@ -0,0 +1,151 @@ +package logic + +import ( + "context" + + authentity "spark-server/internal/module/auth/entity" + userentity "spark-server/internal/module/user/entity" + userlogic "spark-server/internal/module/user/logic" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" +) + +// Verifier checks email OTP and records the login device. The user module owns the implementation. +type Verifier interface { + AuthenticateEmail(ctx context.Context, email, verifyCode string, device *userentity.LoginDevice) (*userentity.User, error) + SendEmailCode(ctx context.Context, email string) error +} + +// LoginInput is the HTTP/TCP-shared login body. Validation tags drive client-visible messages. +type LoginInput struct { + Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required;email:validate.login.email_invalid"` + VerifyCode string `json:"verify_code" validate:"required" label:"field.verify_code" errmsg:"required:validate.login.code_required"` + DeviceID uint64 `json:"device_id" validate:"required" label:"field.device_id" errmsg:"required:validate.login.device_required"` + DeviceType string `json:"device_type" validate:"omitempty,oneof=ios android harmony windows macos web" label:"field.device_type" errmsg:"oneof:validate.login.device_type_invalid"` + Platform string `json:"platform" validate:"omitempty,oneof=ios android harmony windows macos web" label:"field.device_type" errmsg:"oneof:validate.login.device_type_invalid"` + DeviceName string `json:"device_name" validate:"omitempty,max=128" label:"field.device_name"` +} + +// SendCodeInput requests an email OTP. Test accounts do not need this. +type SendCodeInput struct { + Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required;email:validate.login.email_invalid"` +} + +type SentCode struct { + ExpiresIn int64 `json:"expires_in"` +} + +type RefreshInput struct { + RefreshToken string `json:"refresh_token" validate:"required" label:"field.refresh_token" errmsg:"required:validate.login.refresh_required"` +} + +// SessionKicker drops the IM connection for one device after HTTP logout. +type SessionKicker interface { + KickDevice(userID int64, deviceID uint64) +} + +// LoginService is the application login use-case. Handlers only bind and call this. +type LoginService struct { + Issuer Issuer + Users Verifier + Sessions SessionKicker +} + +func NewLoginService(issuer Issuer, users Verifier) *LoginService { + return &LoginService{Issuer: issuer, Users: users} +} + +func (s *LoginService) Login(ctx context.Context, in *LoginInput) (*authentity.IssuedToken, error) { + if s == nil || s.Issuer == nil || s.Users == nil || in == nil { + return nil, errs.ErrUnauthorized + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return nil, err + } + if !userentity.ValidDeviceType(loginDeviceType(in)) { + return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginDeviceTypeRequired, nil)) + } + u, err := s.Users.AuthenticateEmail(ctx, in.Email, in.VerifyCode, loginDevice(ctx, in)) + if err != nil { + return nil, err + } + if u == nil || u.ID <= 0 { + return nil, errs.ErrUnauthorized + } + tok, err := s.Issuer.Issue(ctx, u.ID, in.DeviceID) + if err != nil { + return nil, err + } + tok.SparkID = u.SparkID + return tok, nil +} + +func (s *LoginService) SendEmailCode(ctx context.Context, in SendCodeInput) (*SentCode, error) { + if s == nil || s.Users == nil { + return nil, errs.ErrAvailable + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return nil, err + } + if err := s.Users.SendEmailCode(ctx, in.Email); err != nil { + return nil, err + } + return &SentCode{ExpiresIn: userlogic.EmailCodeTTLSeconds}, nil +} + +func (s *LoginService) Refresh(ctx context.Context, in RefreshInput) (*authentity.IssuedToken, error) { + if s == nil || s.Issuer == nil { + return nil, errs.ErrUnauthorized + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return nil, err + } + return s.Issuer.Refresh(ctx, in.RefreshToken) +} + +func (s *LoginService) Logout(ctx context.Context) error { + if s == nil || s.Issuer == nil { + return errs.ErrUnauthorized + } + rc := reqctx.From(ctx) + if rc == nil || rc.UserID <= 0 || rc.DeviceID == 0 { + return errs.ErrUnauthorized + } + if err := s.Issuer.Logout(ctx, rc.UserID, rc.DeviceID, rc.JTI, rc.TokenExpires); err != nil { + return err + } + if s.Sessions != nil { + s.Sessions.KickDevice(rc.UserID, rc.DeviceID) + } + return nil +} + +func loginDeviceType(in *LoginInput) string { + if in == nil { + return "" + } + if t := userentity.NormalizeDeviceType(in.DeviceType); userentity.ValidDeviceType(t) { + return t + } + return userentity.NormalizeDeviceType(in.Platform) +} + +func loginDevice(ctx context.Context, in *LoginInput) *userentity.LoginDevice { + if in == nil { + return &userentity.LoginDevice{} + } + d := &userentity.LoginDevice{ + DeviceID: in.DeviceID, + DeviceType: loginDeviceType(in), + Platform: in.Platform, + DeviceName: in.DeviceName, + RealIP: reqctx.RealIP(ctx), + Language: reqctx.Language(ctx), + } + if rc := reqctx.From(ctx); rc != nil { + d.AppVersion = rc.AppVersion + } + return d +} diff --git a/internal/module/auth/logic/login_test.go b/internal/module/auth/logic/login_test.go new file mode 100644 index 0000000..7bfe8f0 --- /dev/null +++ b/internal/module/auth/logic/login_test.go @@ -0,0 +1,92 @@ +package logic + +import ( + "errors" + "strings" + "testing" + + userlogic "spark-server/internal/module/user/logic" + usermodel "spark-server/internal/module/user/model" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" +) + +func TestLoginServiceValidatesAndIssues(t *testing.T) { + j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "spark-im"}) + if err != nil { + t.Fatal(err) + } + svc := NewLoginService(j, StaticUser{Email: "test01@spark.com", UserID: 11, SparkID: "test01"}) + ctx := reqctx.With(t.Context(), &reqctx.RequestContext{RealIP: "203.0.113.10"}) + + _, err = svc.Login(ctx, &LoginInput{Email: "test01@spark.com"}) + app, ok := errs.AsAppError(err) + if !ok || app.Code != errs.ErrInvalidArgument.Code || !strings.Contains(app.Msg, "请填写验证码") { + t.Fatalf("validate %+v", err) + } + + enCtx := reqctx.With(t.Context(), &reqctx.RequestContext{Language: "en"}) + _, err = svc.Login(enCtx, &LoginInput{Email: "test01@spark.com"}) + app, ok = errs.AsAppError(err) + if !ok || !strings.Contains(app.Msg, "Please enter the verification code") { + t.Fatalf("en validate %+v", err) + } + + tok, err := svc.Login(ctx, &LoginInput{Email: "test01@spark.com", VerifyCode: "any", DeviceID: 7, DeviceType: "ios"}) + if err != nil { + t.Fatal(err) + } + if tok.UserID != 11 || tok.DeviceID != 7 || tok.AccessToken == "" || tok.RefreshToken == "" || tok.SparkID != "test01" { + t.Fatalf("%+v", tok) + } +} + +func TestLoginServiceUnauthorized(t *testing.T) { + j, err := NewJWT(&Options{Secret: "test-secret-test-secret"}) + if err != nil { + t.Fatal(err) + } + in := LoginInput{Email: "ada@example.com", VerifyCode: "000000", DeviceID: 1, DeviceType: "web"} + svc := NewLoginService(j, StaticUser{Email: "ada@example.com", Code: "123456", UserID: 11}) + if _, err = svc.Login(t.Context(), &in); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("bad code: %v", err) + } + in.VerifyCode = "123456" + if _, err = NewLoginService(j, nil).Login(t.Context(), &in); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("nil verifier: %v", err) + } +} + +func TestLoginServiceRecordsContextDevice(t *testing.T) { + j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "spark-im"}) + if err != nil { + t.Fatal(err) + } + repo := usermodel.NewMemoryRepo() + ids := seqIDs{n: 1000} + users := userlogic.NewService(repo, usermodel.NewMemoryCodes(), &ids, nil) + svc := NewLoginService(j, users) + ctx := reqctx.With(t.Context(), &reqctx.RequestContext{ + RealIP: "198.51.100.9", Language: "en", AppVersion: "2.1.0", + }) + tok, err := svc.Login(ctx, &LoginInput{ + Email: "test07@spark.com", VerifyCode: "x", DeviceID: 9, Platform: "android", DeviceName: "Pixel", + }) + if err != nil { + t.Fatal(err) + } + dev, err := repo.GetDevice(ctx, tok.UserID, 9) + if err != nil { + t.Fatal(err) + } + if dev.DeviceType != "android" || dev.DeviceName != "Pixel" || dev.LastIP != "198.51.100.9" || dev.AppVersion != "2.1.0" || dev.Language != "en" { + t.Fatalf("%+v", dev) + } +} + +type seqIDs struct{ n int64 } + +func (s *seqIDs) NextID() (int64, error) { + s.n++ + return s.n, nil +} diff --git a/internal/module/auth/logic/refresh.go b/internal/module/auth/logic/refresh.go new file mode 100644 index 0000000..59751dc --- /dev/null +++ b/internal/module/auth/logic/refresh.go @@ -0,0 +1,135 @@ +package logic + +import ( + "context" + "encoding/json" + "strconv" + "sync" + "time" + + "github.com/redis/go-redis/v9" + + "spark-server/internal/pkg/errs" +) + +const ( + tokenUseAccess = "access" + tokenUseRefresh = "refresh" +) + +// TokenPair is the current access+refresh jti for one user device. +type TokenPair struct { + Access string `json:"a"` + Refresh string `json:"r"` +} + +// PairStore holds the live token pair per device so refresh can rotate and logout can revoke both. +type PairStore interface { + Put(ctx context.Context, userID int64, deviceID uint64, pair TokenPair, ttl time.Duration) error + Get(ctx context.Context, userID int64, deviceID uint64) (TokenPair, error) + Delete(ctx context.Context, userID int64, deviceID uint64) error +} + +type MemoryPairs struct { + mu sync.Mutex + items map[string]storedPair +} + +type storedPair struct { + pair TokenPair + exp int64 +} + +func NewMemoryPairs() *MemoryPairs { + return &MemoryPairs{items: make(map[string]storedPair)} +} + +func pairKey(userID int64, deviceID uint64) string { + return strconv.FormatInt(userID, 10) + ":" + strconv.FormatUint(deviceID, 10) +} + +func (s *MemoryPairs) Put(_ context.Context, userID int64, deviceID uint64, pair TokenPair, ttl time.Duration) error { + if userID <= 0 || deviceID == 0 || ttl <= 0 { + return nil + } + s.mu.Lock() + s.items[pairKey(userID, deviceID)] = storedPair{pair: pair, exp: time.Now().Add(ttl).UnixNano()} + s.mu.Unlock() + return nil +} + +func (s *MemoryPairs) Get(_ context.Context, userID int64, deviceID uint64) (TokenPair, error) { + s.mu.Lock() + defer s.mu.Unlock() + row, ok := s.items[pairKey(userID, deviceID)] + if !ok { + return TokenPair{}, nil + } + if time.Now().UnixNano() >= row.exp { + delete(s.items, pairKey(userID, deviceID)) + return TokenPair{}, nil + } + return row.pair, nil +} + +func (s *MemoryPairs) Delete(_ context.Context, userID int64, deviceID uint64) error { + s.mu.Lock() + delete(s.items, pairKey(userID, deviceID)) + s.mu.Unlock() + return nil +} + +type RedisPairs struct { + rdb *redis.Client + prefix string +} + +func NewRedisPairs(rdb *redis.Client, prefix string) *RedisPairs { + if prefix == "" { + prefix = "refresh:" + } + return &RedisPairs{rdb: rdb, prefix: prefix} +} + +func (s *RedisPairs) key(userID int64, deviceID uint64) string { + return s.prefix + pairKey(userID, deviceID) +} + +func (s *RedisPairs) Put(ctx context.Context, userID int64, deviceID uint64, pair TokenPair, ttl time.Duration) error { + if s == nil || s.rdb == nil { + return errs.ErrAvailable.WithInnerMsg("jwt refresh redis not ready") + } + if userID <= 0 || deviceID == 0 || ttl <= 0 { + return nil + } + raw, err := json.Marshal(pair) + if err != nil { + return err + } + return s.rdb.Set(ctx, s.key(userID, deviceID), raw, ttl).Err() +} + +func (s *RedisPairs) Get(ctx context.Context, userID int64, deviceID uint64) (TokenPair, error) { + if s == nil || s.rdb == nil { + return TokenPair{}, errs.ErrAvailable.WithInnerMsg("jwt refresh redis not ready") + } + raw, err := s.rdb.Get(ctx, s.key(userID, deviceID)).Bytes() + if err == redis.Nil { + return TokenPair{}, nil + } + if err != nil { + return TokenPair{}, err + } + var pair TokenPair + if err := json.Unmarshal(raw, &pair); err != nil { + return TokenPair{}, err + } + return pair, nil +} + +func (s *RedisPairs) Delete(ctx context.Context, userID int64, deviceID uint64) error { + if s == nil || s.rdb == nil { + return errs.ErrAvailable.WithInnerMsg("jwt refresh redis not ready") + } + return s.rdb.Del(ctx, s.key(userID, deviceID)).Err() +} diff --git a/internal/module/auth/logic/static.go b/internal/module/auth/logic/static.go new file mode 100644 index 0000000..48c3dda --- /dev/null +++ b/internal/module/auth/logic/static.go @@ -0,0 +1,47 @@ +package logic + +import ( + "context" + + authentity "spark-server/internal/module/auth/entity" + userentity "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" +) + +// Static accepts one opaque token. Tests and local bring-up only; production uses JWT. +type Static struct { + Token string + UserID int64 + DeviceID uint64 +} + +func (s Static) Authenticate(_ context.Context, token string) (*authentity.Principal, error) { + if s.Token == "" || token != s.Token { + return nil, errs.ErrUnauthorized + } + return &authentity.Principal{UserID: s.UserID, DeviceID: s.DeviceID}, nil +} + +// StaticUser is a single email/OTP pair for tests that do not need the user module. +type StaticUser struct { + Email string + Code string + UserID int64 + SparkID string +} + +func (s StaticUser) AuthenticateEmail(_ context.Context, email, verifyCode string, _ *userentity.LoginDevice) (*userentity.User, error) { + if s.Email == "" || userentity.NormalizeEmail(email) != userentity.NormalizeEmail(s.Email) { + return nil, errs.ErrUnauthorized + } + if s.Code != "" && verifyCode != s.Code { + return nil, errs.ErrUnauthorized + } + sparkID := s.SparkID + if sparkID == "" { + sparkID = "static" + } + return &userentity.User{ID: s.UserID, SparkID: sparkID, Status: userentity.StatusActive}, nil +} + +func (s StaticUser) SendEmailCode(context.Context, string) error { return nil } diff --git a/internal/module/auth/service.go b/internal/module/auth/service.go deleted file mode 100644 index a48d6c9..0000000 --- a/internal/module/auth/service.go +++ /dev/null @@ -1,12 +0,0 @@ -package auth - -import "context" - -type Principal struct { - UserID int64 - DeviceID uint64 -} - -type Service interface { - Authenticate(ctx context.Context, token string) (*Principal, error) -} diff --git a/internal/module/auth/static.go b/internal/module/auth/static.go deleted file mode 100644 index c17cb93..0000000 --- a/internal/module/auth/static.go +++ /dev/null @@ -1,21 +0,0 @@ -package auth - -import ( - "context" - - "spark-server/internal/pkg/errs" -) - -// Static accepts one token. Used in tests and local bring-up. -type Static struct { - Token string - UserID int64 - DeviceID uint64 -} - -func (s Static) Authenticate(_ context.Context, token string) (*Principal, error) { - if s.Token == "" || token != s.Token { - return nil, errs.ErrUnauthorized - } - return &Principal{UserID: s.UserID, DeviceID: s.DeviceID}, nil -} diff --git a/internal/module/conversation/entity/conversation.go b/internal/module/conversation/entity/conversation.go new file mode 100644 index 0000000..c1f0795 --- /dev/null +++ b/internal/module/conversation/entity/conversation.go @@ -0,0 +1,15 @@ +// Package entity holds conversation domain types. +package entity + +const ( + TypeP2P = 1 + TypeGroup = 2 +) + +// Conversation is a P2P or group chat. LastMsgID is the Snowflake cursor, not ConversationSeq. +type Conversation struct { + ID int64 + Type int + LastMsgID int64 + LastMsgAt int64 +} diff --git a/internal/module/conversation/entity/member.go b/internal/module/conversation/entity/member.go new file mode 100644 index 0000000..2c4b963 --- /dev/null +++ b/internal/module/conversation/entity/member.go @@ -0,0 +1,10 @@ +package entity + +// Member is a row in conversation_members. LastReadMsgID is the durable read cursor. +type Member struct { + ConversationID int64 + UserID int64 + LastReadMsgID int64 + JoinedAtMS int64 + Muted bool +} diff --git a/internal/module/conversation/entity/p2p.go b/internal/module/conversation/entity/p2p.go new file mode 100644 index 0000000..9aecc9e --- /dev/null +++ b/internal/module/conversation/entity/p2p.go @@ -0,0 +1,15 @@ +package entity + +// P2P is the unique pair mapping. UserSmall is min(a,b), UserBig is max(a,b). +type P2P struct { + UserSmall int64 + UserBig int64 + ConversationID int64 +} + +func OrderUsers(a, b int64) (small, big int64) { + if a <= b { + return a, b + } + return b, a +} diff --git a/internal/module/conversation/logic/conversation.go b/internal/module/conversation/logic/conversation.go new file mode 100644 index 0000000..68ff999 --- /dev/null +++ b/internal/module/conversation/logic/conversation.go @@ -0,0 +1,17 @@ +// Package logic is the conversation use-case layer. +package logic + +import ( + "context" + + "spark-server/internal/module/conversation/entity" +) + +type Repository interface { + Create(ctx context.Context, c *entity.Conversation) error + Get(ctx context.Context, id int64) (*entity.Conversation, error) + GetP2P(ctx context.Context, userA, userB int64) (*entity.Conversation, error) + UpdateLastMsg(ctx context.Context, id, msgID, atMS int64) error + GetMember(ctx context.Context, conversationID, userID int64) (*entity.Member, error) + UpdateLastRead(ctx context.Context, conversationID, userID, msgID int64) error +} diff --git a/internal/module/conversation/logic/p2p.go b/internal/module/conversation/logic/p2p.go new file mode 100644 index 0000000..075f416 --- /dev/null +++ b/internal/module/conversation/logic/p2p.go @@ -0,0 +1,72 @@ +package logic + +import ( + "context" + "errors" + "time" + + "spark-server/internal/module/conversation/entity" + "spark-server/internal/pkg/errs" +) + +type IDGen interface { + NextID() (int64, error) +} + +type P2PRepository interface { + GetP2P(ctx context.Context, userA, userB int64) (*entity.Conversation, error) + CreateP2P(ctx context.Context, c *entity.Conversation, p2p *entity.P2P, members []*entity.Member) error + UpdateLastMsg(ctx context.Context, id, msgID, atMS int64) error +} + +// P2PService opens the unique 1:1 conversation for a user pair. +type P2PService struct { + repo P2PRepository + ids IDGen +} + +func NewP2PService(repo P2PRepository, ids IDGen) *P2PService { + return &P2PService{repo: repo, ids: ids} +} + +func (s *P2PService) EnsureP2P(ctx context.Context, userA, userB int64) (int64, error) { + if s == nil || s.repo == nil || s.ids == nil || userA <= 0 || userB <= 0 || userA == userB { + return 0, errs.ErrInvalidArgument + } + if c, err := s.repo.GetP2P(ctx, userA, userB); err == nil { + return c.ID, nil + } else if !errors.Is(err, errs.ErrNotFound) { + return 0, err + } + id, err := s.ids.NextID() + if err != nil { + return 0, err + } + now := time.Now() + small, big := entity.OrderUsers(userA, userB) + conv := &entity.Conversation{ID: id, Type: entity.TypeP2P, LastMsgAt: now.UnixMilli()} + p2p := &entity.P2P{UserSmall: small, UserBig: big, ConversationID: id} + joined := now.UnixMilli() + members := []*entity.Member{ + {ConversationID: id, UserID: userA, JoinedAtMS: joined}, + {ConversationID: id, UserID: userB, JoinedAtMS: joined}, + } + if err := s.repo.CreateP2P(ctx, conv, p2p, members); err != nil { + if errors.Is(err, errs.ErrDuplicate) { + c, err := s.repo.GetP2P(ctx, userA, userB) + if err != nil { + return 0, err + } + return c.ID, nil + } + return 0, err + } + return id, nil +} + +func (s *P2PService) UpdateLastMsg(ctx context.Context, conversationID, msgID, atMS int64) error { + if s == nil || s.repo == nil { + return errs.ErrAvailable + } + return s.repo.UpdateLastMsg(ctx, conversationID, msgID, atMS) +} diff --git a/internal/module/conversation/model/conversation.go b/internal/module/conversation/model/conversation.go new file mode 100644 index 0000000..e071606 --- /dev/null +++ b/internal/module/conversation/model/conversation.go @@ -0,0 +1,15 @@ +// Package model is conversation persistence. One file per table. +package model + +import "time" + +// Conversation maps table conversations. +type Conversation struct { + ID int64 `gorm:"column:id;primaryKey;comment:Snowflake 会话 id,时间有序主键"` + Type int `gorm:"column:type;comment:会话类型:1=单聊 2=群聊"` + LastMsgID int64 `gorm:"column:last_msg_id;comment:最新消息 Snowflake id,0 表示尚无消息"` + LastMsgAt time.Time `gorm:"column:last_msg_at;comment:最新消息时间(毫秒精度)"` + CreatedAt time.Time `gorm:"column:created_at;comment:会话创建时间"` +} + +func (Conversation) TableName() string { return "conversations" } diff --git a/internal/module/conversation/model/conversation_member.go b/internal/module/conversation/model/conversation_member.go new file mode 100644 index 0000000..2526533 --- /dev/null +++ b/internal/module/conversation/model/conversation_member.go @@ -0,0 +1,47 @@ +package model + +import ( + "context" + "time" + + "spark-server/internal/module/conversation/entity" + "spark-server/internal/pkg/errs" +) + +// ConversationMember maps table conversation_members. +type ConversationMember struct { + ConversationID int64 `gorm:"column:conversation_id;primaryKey;comment:会话 id"` + UserID int64 `gorm:"column:user_id;primaryKey;comment:成员 user_id"` + LastReadMsgID int64 `gorm:"column:last_read_msg_id;comment:该成员已读到的最大消息 id,未读真相;Redis 丢了可从此重建"` + JoinedAt time.Time `gorm:"column:joined_at;comment:加入会话时间"` + Muted int `gorm:"column:muted;comment:0=未静音 1=免打扰"` +} + +func (ConversationMember) TableName() string { return "conversation_members" } + +func (r *ConversationMember) entity() *entity.Member { + if r == nil { + return nil + } + m := &entity.Member{ + ConversationID: r.ConversationID, UserID: r.UserID, + LastReadMsgID: r.LastReadMsgID, JoinedAtMS: r.JoinedAt.UnixMilli(), + } + m.Muted = r.Muted != 0 + return m +} + +func (m *Repo) GetMember(ctx context.Context, conversationID, userID int64) (*entity.Member, error) { + if conversationID <= 0 || userID <= 0 { + return nil, errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row ConversationMember + if err := db.Where("conversation_id = ? AND user_id = ?", conversationID, userID).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.entity(), nil +} diff --git a/internal/module/conversation/model/memory.go b/internal/module/conversation/model/memory.go new file mode 100644 index 0000000..27cc406 --- /dev/null +++ b/internal/module/conversation/model/memory.go @@ -0,0 +1,102 @@ +package model + +import ( + "context" + "sync" + "time" + + "spark-server/internal/module/conversation/entity" + "spark-server/internal/pkg/errs" +) + +type p2pKey struct { + Small int64 + Big int64 +} + +// MemoryP2P is an in-process conversation store for tests and empty-DSN bring-up. +type MemoryP2P struct { + mu sync.Mutex + byID map[int64]*entity.Conversation + byP2P map[p2pKey]int64 + members map[int64]map[int64]*entity.Member +} + +func NewMemoryP2P() *MemoryP2P { + return &MemoryP2P{ + byID: make(map[int64]*entity.Conversation), + byP2P: make(map[p2pKey]int64), + members: make(map[int64]map[int64]*entity.Member), + } +} + +func (m *MemoryP2P) GetP2P(_ context.Context, userA, userB int64) (*entity.Conversation, error) { + m.mu.Lock() + defer m.mu.Unlock() + small, big := entity.OrderUsers(userA, userB) + id, ok := m.byP2P[p2pKey{Small: small, Big: big}] + if !ok { + return nil, errs.ErrNotFound + } + cp := *m.byID[id] + return &cp, nil +} + +func (m *MemoryP2P) CreateP2P(_ context.Context, c *entity.Conversation, p2p *entity.P2P, members []*entity.Member) error { + if c == nil || p2p == nil { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + key := p2pKey{Small: p2p.UserSmall, Big: p2p.UserBig} + if _, ok := m.byP2P[key]; ok { + return errs.ErrDuplicate + } + if _, ok := m.byID[c.ID]; ok { + return errs.ErrDuplicate + } + cp := *c + m.byID[c.ID] = &cp + m.byP2P[key] = c.ID + byUser := make(map[int64]*entity.Member, len(members)) + for _, mem := range members { + if mem == nil { + continue + } + row := *mem + byUser[mem.UserID] = &row + } + m.members[c.ID] = byUser + return nil +} + +func (m *MemoryP2P) GetMember(_ context.Context, conversationID, userID int64) (*entity.Member, error) { + m.mu.Lock() + defer m.mu.Unlock() + byUser, ok := m.members[conversationID] + if !ok { + return nil, errs.ErrNotFound + } + mem, ok := byUser[userID] + if !ok { + return nil, errs.ErrNotFound + } + cp := *mem + return &cp, nil +} + +func (m *MemoryP2P) UpdateLastMsg(_ context.Context, id, msgID, atMS int64) error { + m.mu.Lock() + defer m.mu.Unlock() + c, ok := m.byID[id] + if !ok { + return errs.ErrNotFound + } + c.LastMsgID = msgID + if atMS > 0 { + c.LastMsgAt = atMS + } else { + c.LastMsgAt = time.Now().UnixMilli() + } + return nil +} diff --git a/internal/module/conversation/model/p2p_conversation.go b/internal/module/conversation/model/p2p_conversation.go new file mode 100644 index 0000000..c377592 --- /dev/null +++ b/internal/module/conversation/model/p2p_conversation.go @@ -0,0 +1,10 @@ +package model + +// P2PConversation maps table p2p_conversations. +type P2PConversation struct { + UserSmall int64 `gorm:"column:user_small;primaryKey;comment:两人中较小的 user_id,保证同一对用户只有一行"` + UserBig int64 `gorm:"column:user_big;primaryKey;comment:两人中较大的 user_id"` + ConversationID int64 `gorm:"column:conversation_id;comment:对应 conversations.id"` +} + +func (P2PConversation) TableName() string { return "p2p_conversations" } diff --git a/internal/module/conversation/model/repo.go b/internal/module/conversation/model/repo.go new file mode 100644 index 0000000..210d8e9 --- /dev/null +++ b/internal/module/conversation/model/repo.go @@ -0,0 +1,138 @@ +package model + +import ( + "context" + "errors" + "time" + + mysqldriver "github.com/go-sql-driver/mysql" + "gorm.io/gorm" + + "spark-server/internal/module/conversation/entity" + "spark-server/internal/pkg/errs" +) + +// Repo is MySQL persistence for conversations / p2p / members. +type Repo struct { + gdb *gorm.DB +} + +func NewRepo(gdb *gorm.DB) *Repo { + return &Repo{gdb: gdb} +} + +func (m *Repo) session(ctx context.Context) (*gorm.DB, error) { + if m == nil || m.gdb == nil { + return nil, errs.ErrAvailable + } + return m.gdb.WithContext(ctx), nil +} + +func mapDBErr(err error) error { + if err == nil { + return nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return errs.ErrNotFound + } + var me *mysqldriver.MySQLError + if errors.As(err, &me) && me.Number == 1062 { + return errs.ErrDuplicate + } + return errs.ErrInner.WithError("conversation-mysql", err) +} + +func (m *Repo) Get(ctx context.Context, id int64) (*entity.Conversation, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row Conversation + if err := db.Where("id = ?", id).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.entity(), nil +} + +func (m *Repo) GetP2P(ctx context.Context, userA, userB int64) (*entity.Conversation, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + small, big := entity.OrderUsers(userA, userB) + var row P2PConversation + if err := db.Where("user_small = ? AND user_big = ?", small, big).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return m.Get(ctx, row.ConversationID) +} + +func (m *Repo) CreateP2P(ctx context.Context, c *entity.Conversation, p2p *entity.P2P, members []*entity.Member) error { + if c == nil || p2p == nil { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + return db.Transaction(func(tx *gorm.DB) error { + conv := Conversation{ + ID: c.ID, Type: c.Type, LastMsgID: c.LastMsgID, + LastMsgAt: msTime(c.LastMsgAt), CreatedAt: msTime(c.LastMsgAt), + } + if err := tx.Create(&conv).Error; err != nil { + return mapDBErr(err) + } + link := P2PConversation{UserSmall: p2p.UserSmall, UserBig: p2p.UserBig, ConversationID: p2p.ConversationID} + if err := tx.Create(&link).Error; err != nil { + return mapDBErr(err) + } + for _, mem := range members { + row := ConversationMember{ + ConversationID: mem.ConversationID, UserID: mem.UserID, + LastReadMsgID: mem.LastReadMsgID, JoinedAt: msTime(mem.JoinedAtMS), + } + if mem.Muted { + row.Muted = 1 + } + if err := tx.Create(&row).Error; err != nil { + return mapDBErr(err) + } + } + return nil + }) +} + +func (m *Repo) UpdateLastMsg(ctx context.Context, id, msgID, atMS int64) error { + db, err := m.session(ctx) + if err != nil { + return err + } + res := db.Model(&Conversation{}).Where("id = ?", id).Updates(map[string]any{ + "last_msg_id": msgID, + "last_msg_at": msTime(atMS), + }) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return nil +} + +func (r *Conversation) entity() *entity.Conversation { + if r == nil { + return nil + } + return &entity.Conversation{ + ID: r.ID, Type: r.Type, LastMsgID: r.LastMsgID, LastMsgAt: r.LastMsgAt.UnixMilli(), + } +} + +func msTime(ms int64) time.Time { + if ms <= 0 { + return time.Now() + } + return time.UnixMilli(ms) +} diff --git a/internal/module/conversation/repository.go b/internal/module/conversation/repository.go deleted file mode 100644 index 1fdff57..0000000 --- a/internal/module/conversation/repository.go +++ /dev/null @@ -1,22 +0,0 @@ -package conversation - -import "context" - -const ( - TypeP2P int = 1 - TypeGroup int = 2 -) - -type Conversation struct { - ID int64 - Type int - LastMsgID int64 - LastMsgAt int64 -} - -type Repository interface { - Create(ctx context.Context, c *Conversation) error - Get(ctx context.Context, id int64) (*Conversation, error) - GetP2P(ctx context.Context, userA, userB int64) (*Conversation, error) - UpdateLastMsg(ctx context.Context, id, msgID, atMS int64) error -} diff --git a/internal/module/deliver/entity/job.go b/internal/module/deliver/entity/job.go new file mode 100644 index 0000000..6fa7763 --- /dev/null +++ b/internal/module/deliver/entity/job.go @@ -0,0 +1,30 @@ +// Package entity holds deliver-queue job types. +package entity + +// Job is a fanout unit. ExcludeDevice is the sender device. NotMailbox matches BFF NotSaveIch. +type Job struct { + MsgID int64 `json:"msg_id"` + Cmd uint16 `json:"cmd"` + ConversationID int64 `json:"conversation_id"` + FromUserID int64 `json:"from_user_id"` + FromDeviceID uint64 `json:"from_device_id"` + ExcludeDevice uint64 `json:"exclude_device"` + ToUserIDs []int64 `json:"to_user_ids,omitempty"` + NotPush bool `json:"not_push,omitempty"` + NotMailbox bool `json:"not_mailbox,omitempty"` + Payload []byte `json:"payload,omitempty"` + raw []byte `json:"-"` +} + +func (j *Job) Raw() []byte { + if j == nil { + return nil + } + return j.raw +} + +func (j *Job) SetRaw(raw []byte) { + if j != nil { + j.raw = raw + } +} diff --git a/internal/module/deliver/logic/done.go b/internal/module/deliver/logic/done.go new file mode 100644 index 0000000..89989db --- /dev/null +++ b/internal/module/deliver/logic/done.go @@ -0,0 +1,8 @@ +package logic + +import "context" + +// Deduper is SETNX deliver:done:{msgId} so a retried job cannot increment unread twice. +type Deduper interface { + Mark(ctx context.Context, msgID int64) (first bool, err error) +} diff --git a/internal/module/deliver/logic/queue.go b/internal/module/deliver/logic/queue.go new file mode 100644 index 0000000..1a7221e --- /dev/null +++ b/internal/module/deliver/logic/queue.go @@ -0,0 +1,15 @@ +package logic + +import ( + "context" + "time" + + "spark-server/internal/module/deliver/entity" +) + +type Queue interface { + Push(ctx context.Context, job *entity.Job) error + Take(ctx context.Context, timeout time.Duration) (*entity.Job, error) + Ack(ctx context.Context, job *entity.Job) error + RequeueStale(ctx context.Context) error +} diff --git a/internal/module/deliver/logic/worker.go b/internal/module/deliver/logic/worker.go new file mode 100644 index 0000000..b880ec1 --- /dev/null +++ b/internal/module/deliver/logic/worker.go @@ -0,0 +1,161 @@ +// Package logic is the deliver worker and queue contracts. +package logic + +import ( + "context" + "sync" + "time" + + "go.uber.org/zap" + + "spark-server/internal/infrastructure/logger" + "spark-server/internal/module/deliver/entity" + "spark-server/internal/transport/tcp/protocol" + "spark-server/internal/transport/tcp/session" +) + +// Handler fans a job out. Return error to leave the job in processing for RequeueStale. +type Handler func(ctx context.Context, job *entity.Job) error + +type Worker struct { + log *zap.Logger + queue Queue + done Deduper + handle Handler + n int + timeout time.Duration + stop chan struct{} + wg sync.WaitGroup +} + +type WorkerOptions struct { + Workers int + TakeTimeout time.Duration + Handler Handler +} + +func NewWorker(log *zap.Logger, q Queue, done Deduper, o WorkerOptions) *Worker { + if log == nil { + log = zap.NewNop() + } + if o.Workers < 1 { + o.Workers = 1 + } + if o.TakeTimeout <= 0 { + o.TakeTimeout = 5 * time.Second + } + if done == nil { + done = NopDeduper{} + } + if o.Handler == nil { + o.Handler = NopHandler + } + return &Worker{ + log: log, queue: q, done: done, handle: o.Handler, + n: o.Workers, timeout: o.TakeTimeout, stop: make(chan struct{}), + } +} + +func NopHandler(context.Context, *entity.Job) error { return nil } + +type NopDeduper struct{} + +func (NopDeduper) Mark(context.Context, int64) (bool, error) { return true, nil } + +func SessionFanout(sessions session.Manager) Handler { + return func(ctx context.Context, job *entity.Job) error { + if sessions == nil || job == nil || len(job.ToUserIDs) == 0 { + return nil + } + cmd := job.Cmd + if cmd == 0 { + cmd = protocol.CmdMessage + } + pkt := &session.Packet{Command: cmd, Body: job.Payload} + for _, uid := range job.ToUserIDs { + _ = sessions.SendToUserExceptDevice(ctx, uid, job.ExcludeDevice, pkt) + } + return nil + } +} + +func (w *Worker) Start(ctx context.Context) { + if w == nil || w.queue == nil { + return + } + if err := w.queue.RequeueStale(ctx); err != nil { + w.log.Error("deliver requeue", zap.Error(err)) + } + for i := 0; i < w.n; i++ { + w.wg.Add(1) + go w.loop() + } +} + +func (w *Worker) Stop() { + if w == nil { + return + } + select { + case <-w.stop: + default: + close(w.stop) + } + w.wg.Wait() +} + +func (w *Worker) loop() { + defer w.wg.Done() + for { + select { + case <-w.stop: + return + default: + } + ctx, cancel := context.WithTimeout(context.Background(), w.timeout+time.Second) + job, err := w.queue.Take(ctx, w.timeout) + cancel() + if err != nil { + select { + case <-w.stop: + return + case <-time.After(200 * time.Millisecond): + } + continue + } + if job == nil { + select { + case <-w.stop: + return + default: + continue + } + } + w.process(job) + } +} + +func (w *Worker) process(job *entity.Job) { + defer func() { + if rec := recover(); rec != nil { + w.log.Error("deliver panic", zap.Any("panic", rec), zap.Int64("msg_id", job.MsgID)) + } + }() + ctx := logger.Attach(context.Background(), w.log) + first, err := w.done.Mark(ctx, job.MsgID) + if err != nil { + w.log.Error("deliver done", zap.Error(err), zap.Int64("msg_id", job.MsgID)) + return + } + if !first { + _ = w.queue.Ack(ctx, job) + return + } + if err := w.handle(ctx, job); err != nil { + w.log.Error("deliver handle", zap.Error(err), zap.Int64("msg_id", job.MsgID)) + return + } + if err := w.queue.Ack(ctx, job); err != nil { + w.log.Error("deliver ack", zap.Error(err), zap.Int64("msg_id", job.MsgID)) + } +} diff --git a/internal/module/deliver/logic/worker_test.go b/internal/module/deliver/logic/worker_test.go new file mode 100644 index 0000000..bf89e42 --- /dev/null +++ b/internal/module/deliver/logic/worker_test.go @@ -0,0 +1,110 @@ +package logic + +import ( + "context" + "encoding/json" + "sync/atomic" + "testing" + "time" + + "spark-server/internal/module/deliver/entity" + "spark-server/internal/module/deliver/model" +) + +func TestMemoryQueueAckUsesOriginalBytes(t *testing.T) { + q := model.NewMemoryQueue() + ctx := t.Context() + job := &entity.Job{MsgID: 9, FromUserID: 1, ExcludeDevice: 7, ToUserIDs: []int64{2}} + if err := q.Push(ctx, job); err != nil { + t.Fatal(err) + } + got, err := q.Take(ctx, time.Second) + if err != nil || got == nil || got.MsgID != 9 { + t.Fatalf("take %+v %v", got, err) + } + got.FromUserID = 99 + if err := q.Ack(ctx, got); err != nil { + t.Fatal(err) + } + if err := q.RequeueStale(ctx); err != nil { + t.Fatal(err) + } + again, err := q.Take(ctx, 10*time.Millisecond) + if err != nil { + t.Fatal(err) + } + if again != nil { + t.Fatalf("acked job still in processing %+v", again) + } +} + +func TestDeduperSkipsSecond(t *testing.T) { + d := model.NewMemoryDone() + first, err := d.Mark(t.Context(), 11) + if err != nil || !first { + t.Fatalf("first %v %v", first, err) + } + second, err := d.Mark(t.Context(), 11) + if err != nil || second { + t.Fatalf("second %v %v", second, err) + } +} + +func TestWorkerIdempotentAck(t *testing.T) { + q := model.NewMemoryQueue() + d := model.NewMemoryDone() + var n atomic.Int32 + w := NewWorker(nil, q, d, WorkerOptions{ + Workers: 1, TakeTimeout: 50 * time.Millisecond, + Handler: func(context.Context, *entity.Job) error { n.Add(1); return nil }, + }) + ctx := t.Context() + raw, _ := json.Marshal(&entity.Job{MsgID: 3, Cmd: 6}) + if err := q.Push(ctx, &entity.Job{MsgID: 3}); err != nil { + t.Fatal(err) + } + _ = raw + w.Start(ctx) + defer w.Stop() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && n.Load() == 0 { + time.Sleep(10 * time.Millisecond) + } + if n.Load() != 1 { + t.Fatalf("handled %d", n.Load()) + } + if err := q.Push(ctx, &entity.Job{MsgID: 3}); err != nil { + t.Fatal(err) + } + time.Sleep(150 * time.Millisecond) + if n.Load() != 1 { + t.Fatalf("idempotent %d", n.Load()) + } +} + +func TestWorkerRecoversPanic(t *testing.T) { + q := model.NewMemoryQueue() + var n atomic.Int32 + w := NewWorker(nil, q, model.NewMemoryDone(), WorkerOptions{ + Workers: 1, TakeTimeout: 50 * time.Millisecond, + Handler: func(_ context.Context, job *entity.Job) error { + n.Add(1) + if job.MsgID == 1 { + panic("boom") + } + return nil + }, + }) + ctx := t.Context() + _ = q.Push(ctx, &entity.Job{MsgID: 1}) + _ = q.Push(ctx, &entity.Job{MsgID: 2}) + w.Start(ctx) + defer w.Stop() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && n.Load() < 2 { + time.Sleep(10 * time.Millisecond) + } + if n.Load() < 2 { + t.Fatalf("worker died after panic, handled %d", n.Load()) + } +} diff --git a/internal/module/deliver/model/memory.go b/internal/module/deliver/model/memory.go new file mode 100644 index 0000000..316642b --- /dev/null +++ b/internal/module/deliver/model/memory.go @@ -0,0 +1,117 @@ +package model + +import ( + "bytes" + "context" + "encoding/json" + "sync" + "time" + + "spark-server/internal/module/deliver/entity" +) + +type MemoryQueue struct { + mu sync.Mutex + queue [][]byte + processing [][]byte + wait chan struct{} +} + +func NewMemoryQueue() *MemoryQueue { + return &MemoryQueue{wait: make(chan struct{}, 1)} +} + +func (q *MemoryQueue) Push(_ context.Context, job *entity.Job) error { + raw, err := json.Marshal(job) + if err != nil { + return err + } + q.mu.Lock() + q.queue = append(q.queue, raw) + q.mu.Unlock() + select { + case q.wait <- struct{}{}: + default: + } + return nil +} + +func (q *MemoryQueue) Take(ctx context.Context, timeout time.Duration) (*entity.Job, error) { + deadline := time.Now().Add(timeout) + for { + q.mu.Lock() + if len(q.queue) > 0 { + raw := q.queue[0] + q.queue = q.queue[1:] + q.processing = append(q.processing, raw) + q.mu.Unlock() + job := &entity.Job{} + if err := json.Unmarshal(raw, job); err != nil { + return nil, err + } + job.SetRaw(raw) + return job, nil + } + q.mu.Unlock() + remain := time.Until(deadline) + if remain <= 0 { + return nil, nil + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(remain): + return nil, nil + case <-q.wait: + } + } +} + +func (q *MemoryQueue) Ack(_ context.Context, job *entity.Job) error { + if job == nil { + return nil + } + raw := job.Raw() + q.mu.Lock() + defer q.mu.Unlock() + for i, p := range q.processing { + if bytes.Equal(p, raw) { + q.processing = append(q.processing[:i], q.processing[i+1:]...) + return nil + } + } + return nil +} + +func (q *MemoryQueue) RequeueStale(context.Context) error { + q.mu.Lock() + defer q.mu.Unlock() + if len(q.processing) == 0 { + return nil + } + q.queue = append(q.processing, q.queue...) + q.processing = nil + return nil +} + +type MemoryDone struct { + mu sync.Mutex + seen map[int64]struct{} +} + +func NewMemoryDone() *MemoryDone { + return &MemoryDone{seen: make(map[int64]struct{})} +} + +func (d *MemoryDone) Mark(_ context.Context, msgID int64) (bool, error) { + if msgID <= 0 { + return true, nil + } + d.mu.Lock() + defer d.mu.Unlock() + if _, ok := d.seen[msgID]; ok { + return false, nil + } + d.seen[msgID] = struct{}{} + return true, nil +} diff --git a/internal/module/deliver/model/redis_queue.go b/internal/module/deliver/model/redis_queue.go new file mode 100644 index 0000000..316498e --- /dev/null +++ b/internal/module/deliver/model/redis_queue.go @@ -0,0 +1,122 @@ +// Package model is deliver queue persistence (Redis lists and SETNX done keys). +package model + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + + "spark-server/internal/module/deliver/entity" + "spark-server/internal/pkg/errs" +) + +type RedisQueue struct { + rdb *redis.Client + queueKey string + processKey string +} + +func NewRedisQueue(rdb *redis.Client, queueKey, processKey string) *RedisQueue { + if queueKey == "" { + queueKey = "deliver:queue" + } + if processKey == "" { + processKey = "deliver:processing" + } + return &RedisQueue{rdb: rdb, queueKey: queueKey, processKey: processKey} +} + +func (q *RedisQueue) Push(ctx context.Context, job *entity.Job) error { + if q.rdb == nil { + return errs.ErrInternal + } + raw, err := json.Marshal(job) + if err != nil { + return err + } + return q.rdb.RPush(ctx, q.queueKey, raw).Err() +} + +func (q *RedisQueue) Take(ctx context.Context, timeout time.Duration) (*entity.Job, error) { + if q.rdb == nil { + return nil, errs.ErrInternal + } + if timeout <= 0 { + timeout = 5 * time.Second + } + res, err := q.rdb.BRPopLPush(ctx, q.queueKey, q.processKey, timeout).Result() + if err == redis.Nil { + return nil, nil + } + if err != nil { + return nil, err + } + raw := []byte(res) + job := &entity.Job{} + if err := json.Unmarshal(raw, job); err != nil { + _ = q.rdb.LRem(ctx, q.processKey, 1, res).Err() + return nil, fmt.Errorf("deliver job: %w", err) + } + job.SetRaw(raw) + return job, nil +} + +func (q *RedisQueue) Ack(ctx context.Context, job *entity.Job) error { + if q.rdb == nil || job == nil { + return nil + } + raw := job.Raw() + if len(raw) == 0 { + var err error + raw, err = json.Marshal(job) + if err != nil { + return err + } + } + return q.rdb.LRem(ctx, q.processKey, 1, raw).Err() +} + +func (q *RedisQueue) RequeueStale(ctx context.Context) error { + if q.rdb == nil { + return nil + } + for { + _, err := q.rdb.RPopLPush(ctx, q.processKey, q.queueKey).Result() + if err == redis.Nil { + return nil + } + if err != nil { + return err + } + } +} + +type RedisDone struct { + rdb *redis.Client + prefix string + ttl time.Duration +} + +func NewRedisDone(rdb *redis.Client, prefix string, ttl time.Duration) *RedisDone { + if prefix == "" { + prefix = "deliver:done:" + } + if ttl <= 0 { + ttl = 10 * time.Minute + } + return &RedisDone{rdb: rdb, prefix: prefix, ttl: ttl} +} + +func (d *RedisDone) Mark(ctx context.Context, msgID int64) (bool, error) { + if d == nil || d.rdb == nil || msgID <= 0 { + return true, nil + } + ok, err := d.rdb.SetNX(ctx, fmt.Sprintf("%s%d", d.prefix, msgID), "1", d.ttl).Result() + if err != nil { + return false, err + } + return ok, nil +} diff --git a/internal/module/deliver/queue.go b/internal/module/deliver/queue.go deleted file mode 100644 index 907404e..0000000 --- a/internal/module/deliver/queue.go +++ /dev/null @@ -1,26 +0,0 @@ -package deliver - -import ( - "context" - "time" -) - -// Job is a fanout unit. ExcludeDevice is the sender device. NotMailbox matches BFF NotSaveIch. -type Job struct { - MsgID int64 - Cmd uint16 - ConversationID int64 - FromUserID int64 - FromDeviceID uint64 - ExcludeDevice uint64 - NotPush bool - NotMailbox bool - Payload []byte -} - -type Queue interface { - Push(ctx context.Context, job *Job) error - Take(ctx context.Context, timeout time.Duration) (*Job, error) - Ack(ctx context.Context, job *Job) error - RequeueStale(ctx context.Context) error -} diff --git a/internal/module/deliver/redis_queue.go b/internal/module/deliver/redis_queue.go deleted file mode 100644 index 3ec3a2b..0000000 --- a/internal/module/deliver/redis_queue.go +++ /dev/null @@ -1,78 +0,0 @@ -package deliver - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/redis/go-redis/v9" - - "spark-server/internal/pkg/errs" -) - -type redisQueue struct { - rdb *redis.Client - queueKey string - processKey string -} - -func NewRedisQueue(rdb *redis.Client, queueKey, processKey string) Queue { - return &redisQueue{rdb: rdb, queueKey: queueKey, processKey: processKey} -} - -func (q *redisQueue) Push(ctx context.Context, job *Job) error { - if q.rdb == nil { - return errs.ErrInternal - } - raw, err := json.Marshal(job) - if err != nil { - return err - } - return q.rdb.RPush(ctx, q.queueKey, raw).Err() -} - -func (q *redisQueue) Take(ctx context.Context, timeout time.Duration) (*Job, error) { - if q.rdb == nil { - return nil, errs.ErrInternal - } - res, err := q.rdb.BRPopLPush(ctx, q.queueKey, q.processKey, timeout).Result() - if err == redis.Nil { - return nil, nil - } - if err != nil { - return nil, err - } - job := &Job{} - if err := json.Unmarshal([]byte(res), job); err != nil { - return nil, fmt.Errorf("deliver job: %w", err) - } - return job, nil -} - -func (q *redisQueue) Ack(ctx context.Context, job *Job) error { - if q.rdb == nil || job == nil { - return nil - } - raw, err := json.Marshal(job) - if err != nil { - return err - } - return q.rdb.LRem(ctx, q.processKey, 1, raw).Err() -} - -func (q *redisQueue) RequeueStale(ctx context.Context) error { - if q.rdb == nil { - return nil - } - for { - res, err := q.rdb.RPopLPush(ctx, q.processKey, q.queueKey).Result() - if err == redis.Nil { - return nil - } - if err != nil { - return err - } - _ = res - } -} diff --git a/internal/module/favorite/entity/favorite.go b/internal/module/favorite/entity/favorite.go new file mode 100644 index 0000000..e34151b --- /dev/null +++ b/internal/module/favorite/entity/favorite.go @@ -0,0 +1,28 @@ +// Package entity holds favorite domain types. Media keys in Content are perm/fav copies. +package entity + +const ( + ListDefault = 20 + ListMax = 100 +) + +// Favorite is a user-owned snapshot of a message. Object keys are copied off chat/. +type Favorite struct { + ID int64 + UserID int64 + MsgID int64 + ConversationID int64 + MessageType int16 + Content []byte + CreatedAtMS int64 +} + +func ClampLimit(limit int) int { + if limit <= 0 { + return ListDefault + } + if limit > ListMax { + return ListMax + } + return limit +} diff --git a/internal/module/favorite/logic/copy.go b/internal/module/favorite/logic/copy.go new file mode 100644 index 0000000..a72df2a --- /dev/null +++ b/internal/module/favorite/logic/copy.go @@ -0,0 +1,51 @@ +package logic + +import ( + "context" + "time" + + msgentity "spark-server/internal/module/message/entity" + uploadentity "spark-server/internal/module/upload/entity" + "spark-server/internal/pkg/errs" +) + +func (s *Service) copyMedia(ctx context.Context, userID int64, payload *msgentity.Content) ([]string, error) { + keys := payload.ObjectKeys() + if len(keys) == 0 { + return nil, nil + } + if s.objects == nil { + return nil, errs.ErrAvailable + } + day := time.Now().UTC().Format("20060102") + mapping := make(map[string]string, len(keys)) + copied := make([]string, 0, len(keys)) + for _, src := range keys { + if _, ok := mapping[src]; ok { + continue + } + objectID, err := s.ids.NextID() + if err != nil { + s.deleteKeys(ctx, copied) + return nil, err + } + dst := uploadentity.BuildFavKey(userID, objectID, day, uploadentity.ExtOfKey(src)) + if err := s.objects.Copy(ctx, src, dst); err != nil { + s.deleteKeys(ctx, copied) + return nil, err + } + mapping[src] = dst + copied = append(copied, dst) + } + payload.ReplaceObjectKeys(mapping) + return copied, nil +} + +func (s *Service) deleteKeys(ctx context.Context, keys []string) { + if s.objects == nil { + return + } + for _, key := range keys { + _ = s.objects.Delete(ctx, key) + } +} diff --git a/internal/module/favorite/logic/create.go b/internal/module/favorite/logic/create.go new file mode 100644 index 0000000..4921248 --- /dev/null +++ b/internal/module/favorite/logic/create.go @@ -0,0 +1,117 @@ +package logic + +import ( + "context" + "encoding/json" + "errors" + "time" + + "spark-server/internal/module/favorite/entity" + msgentity "spark-server/internal/module/message/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" +) + +// CreateInput favorites an existing message. Expired chat objects fail; no client re-upload. +type CreateInput struct { + MsgID int64 `json:"msg_id" validate:"required,gt=0" label:"field.msg_id" errmsg:"required:validate.favorite.msg_required;gt:validate.favorite.msg_invalid"` +} + +// Item is the HTTP/TCP favorite view. Content is the snapshot JSON (perm/fav keys). +type Item struct { + ID int64 `json:"id"` + MsgID int64 `json:"msg_id"` + ConversationID int64 `json:"conversation_id"` + MessageType int16 `json:"message_type"` + Content json.RawMessage `json:"content"` + CreatedAt int64 `json:"created_at"` +} + +func (s *Service) Create(ctx context.Context, userID int64, in *CreateInput) (*Item, error) { + if !s.ready() || userID <= 0 { + return nil, errs.ErrUnauthorized + } + if in == nil { + return nil, errs.ErrInvalidArgument + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return nil, err + } + if existing, err := s.repo.GetByUserMsg(ctx, userID, in.MsgID); err == nil { + return itemOf(existing), nil + } else if !errors.Is(err, errs.ErrNotFound) { + return nil, err + } + msg, payload, err := s.loadFavoriteSource(ctx, userID, in.MsgID) + if err != nil { + return nil, err + } + copied, err := s.copyMedia(ctx, userID, payload) + if err != nil { + return nil, err + } + fav, err := s.persistFavorite(ctx, userID, msg, payload) + if err != nil { + s.deleteKeys(ctx, copied) + if errors.Is(err, errs.ErrDuplicate) { + existing, getErr := s.repo.GetByUserMsg(ctx, userID, in.MsgID) + if getErr == nil { + return itemOf(existing), nil + } + } + return nil, err + } + return itemOf(fav), nil +} + +func (s *Service) loadFavoriteSource(ctx context.Context, userID, msgID int64) (*msgentity.Message, *msgentity.Content, error) { + msg, err := s.msgs.GetByID(ctx, msgID) + if err != nil { + return nil, nil, err + } + if msg.Status == msgentity.StatusHidden { + return nil, nil, errs.ErrNotFound + } + if _, err := s.members.GetMember(ctx, msg.ConversationID, userID); err != nil { + if errors.Is(err, errs.ErrNotFound) { + return nil, nil, errs.ErrForbidden + } + return nil, nil, err + } + payload, err := msg.Payload() + if err != nil || payload == nil { + return nil, nil, errs.ErrInvalidArgument + } + return msg, payload, nil +} + +func (s *Service) persistFavorite(ctx context.Context, userID int64, msg *msgentity.Message, payload *msgentity.Content) (*entity.Favorite, error) { + raw, err := payload.Marshal() + if err != nil { + return nil, errs.ErrInvalidArgument + } + id, err := s.ids.NextID() + if err != nil { + return nil, err + } + now := time.Now() + fav := &entity.Favorite{ + ID: id, UserID: userID, MsgID: msg.ID, ConversationID: msg.ConversationID, + MessageType: int16(msg.Type), Content: raw, CreatedAtMS: now.UnixMilli(), + } + if err := s.repo.Create(ctx, fav); err != nil { + return nil, err + } + return fav, nil +} + +func itemOf(fav *entity.Favorite) *Item { + if fav == nil { + return nil + } + return &Item{ + ID: fav.ID, MsgID: fav.MsgID, ConversationID: fav.ConversationID, + MessageType: fav.MessageType, Content: fav.Content, CreatedAt: fav.CreatedAtMS, + } +} diff --git a/internal/module/favorite/logic/favorite_test.go b/internal/module/favorite/logic/favorite_test.go new file mode 100644 index 0000000..970cfd5 --- /dev/null +++ b/internal/module/favorite/logic/favorite_test.go @@ -0,0 +1,136 @@ +package logic + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "spark-server/internal/infrastructure/id" + convlogic "spark-server/internal/module/conversation/logic" + convmodel "spark-server/internal/module/conversation/model" + favmodel "spark-server/internal/module/favorite/model" + msgentity "spark-server/internal/module/message/entity" + msgmodel "spark-server/internal/module/message/model" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" +) + +type memObjects struct { + keys map[string]bool +} + +func (m *memObjects) Copy(_ context.Context, src, dst string) error { + if m.keys == nil || !m.keys[src] { + return errs.ErrMediaExpired + } + m.keys[dst] = true + return nil +} + +func (m *memObjects) Delete(_ context.Context, key string) error { + delete(m.keys, key) + return nil +} + +func testFav(t *testing.T) (*Service, *msgmodel.MemoryRepo, *convmodel.MemoryP2P, *memObjects, *id.Generator) { + t.Helper() + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + msgs := msgmodel.NewMemoryRepo() + convs := convmodel.NewMemoryP2P() + objs := &memObjects{keys: map[string]bool{}} + svc := NewService(favmodel.NewMemoryRepo(), msgs, convs, objs, ids) + return svc, msgs, convs, objs, ids +} + +func TestFavoriteCopiesMediaAndExpires(t *testing.T) { + svc, msgs, convs, objs, ids := testFav(t) + ctx := reqctx.With(t.Context(), &reqctx.RequestContext{UserID: 1, Language: "zh-CN"}) + p2p := convlogic.NewP2PService(convs, ids) + convID, err := p2p.EnsureP2P(ctx, 1, 2) + if err != nil { + t.Fatal(err) + } + src := "chat/20260917/1/99.jpg" + objs.keys[src] = true + raw, err := msgentity.NewImage(&msgentity.Image{Key: src, Mime: "image/jpeg"}).Marshal() + if err != nil { + t.Fatal(err) + } + msgID, err := ids.NextID() + if err != nil { + t.Fatal(err) + } + if err := msgs.Create(ctx, &msgentity.Message{ + ID: msgID, ConversationID: convID, SenderID: 1, ClientMsgID: "c1", + Type: msgentity.TypeImage, Content: raw, Status: msgentity.StatusNormal, + CreatedAtMS: time.Now().UnixMilli(), + }); err != nil { + t.Fatal(err) + } + item, err := svc.Create(ctx, 1, &CreateInput{MsgID: msgID}) + if err != nil { + t.Fatal(err) + } + if item.MsgID != msgID || !strings.Contains(string(item.Content), "perm/fav/1/") { + t.Fatalf("%+v", item) + } + if strings.Contains(string(item.Content), src) { + t.Fatal("still points at chat key") + } + again, err := svc.Create(ctx, 1, &CreateInput{MsgID: msgID}) + if err != nil || again.ID != item.ID { + t.Fatalf("idempotent %+v %v", again, err) + } + + expiredID, err := ids.NextID() + if err != nil { + t.Fatal(err) + } + gone := "chat/20260917/1/gone.jpg" + raw2, err := msgentity.NewImage(&msgentity.Image{Key: gone, Mime: "image/jpeg"}).Marshal() + if err != nil { + t.Fatal(err) + } + if err := msgs.Create(ctx, &msgentity.Message{ + ID: expiredID, ConversationID: convID, SenderID: 1, ClientMsgID: "c2", + Type: msgentity.TypeImage, Content: raw2, Status: msgentity.StatusNormal, + }); err != nil { + t.Fatal(err) + } + _, err = svc.Create(ctx, 1, &CreateInput{MsgID: expiredID}) + if !errors.Is(err, errs.ErrMediaExpired) { + t.Fatalf("expired %v", err) + } +} + +func TestFavoriteForbidsNonMember(t *testing.T) { + svc, msgs, convs, _, ids := testFav(t) + ctx := t.Context() + p2p := convlogic.NewP2PService(convs, ids) + convID, err := p2p.EnsureP2P(ctx, 1, 2) + if err != nil { + t.Fatal(err) + } + raw, err := msgentity.NewText("hi").Marshal() + if err != nil { + t.Fatal(err) + } + msgID, err := ids.NextID() + if err != nil { + t.Fatal(err) + } + if err := msgs.Create(ctx, &msgentity.Message{ + ID: msgID, ConversationID: convID, SenderID: 1, ClientMsgID: "t1", + Type: msgentity.TypeText, Content: raw, Status: msgentity.StatusNormal, + }); err != nil { + t.Fatal(err) + } + if _, err := svc.Create(ctx, 9, &CreateInput{MsgID: msgID}); !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("member %v", err) + } +} diff --git a/internal/module/favorite/logic/list.go b/internal/module/favorite/logic/list.go new file mode 100644 index 0000000..fc91ac5 --- /dev/null +++ b/internal/module/favorite/logic/list.go @@ -0,0 +1,57 @@ +package logic + +import ( + "context" + + "spark-server/internal/module/favorite/entity" + msgentity "spark-server/internal/module/message/entity" + "spark-server/internal/pkg/errs" +) + +type ListResult struct { + Items []*Item `json:"items"` +} + +func (s *Service) List(ctx context.Context, userID int64, limit int) (*ListResult, error) { + if !s.ready() || userID <= 0 { + return nil, errs.ErrUnauthorized + } + rows, err := s.repo.ListByUser(ctx, userID, entity.ClampLimit(limit)) + if err != nil { + return nil, err + } + out := &ListResult{Items: make([]*Item, 0, len(rows))} + for _, row := range rows { + out.Items = append(out.Items, itemOf(row)) + } + return out, nil +} + +func (s *Service) Delete(ctx context.Context, userID, id int64) error { + if !s.ready() || userID <= 0 { + return errs.ErrUnauthorized + } + if id <= 0 { + return errs.ErrInvalidArgument + } + fav, err := s.repo.Get(ctx, id) + if err != nil { + return err + } + if fav.UserID != userID { + return errs.ErrForbidden + } + if err := s.repo.Delete(ctx, userID, id); err != nil { + return err + } + s.deleteFavoriteMedia(ctx, fav.Content) + return nil +} + +func (s *Service) deleteFavoriteMedia(ctx context.Context, raw []byte) { + payload, err := msgentity.UnmarshalContent(raw) + if err != nil { + return + } + s.deleteKeys(ctx, payload.ObjectKeys()) +} diff --git a/internal/module/favorite/logic/service.go b/internal/module/favorite/logic/service.go new file mode 100644 index 0000000..8b81f0a --- /dev/null +++ b/internal/module/favorite/logic/service.go @@ -0,0 +1,52 @@ +package logic + +import ( + "context" + + conventity "spark-server/internal/module/conversation/entity" + "spark-server/internal/module/favorite/entity" + msgentity "spark-server/internal/module/message/entity" +) + +// Repository persists favorites. Model implements it. +type Repository interface { + Get(ctx context.Context, id int64) (*entity.Favorite, error) + GetByUserMsg(ctx context.Context, userID, msgID int64) (*entity.Favorite, error) + Create(ctx context.Context, fav *entity.Favorite) error + ListByUser(ctx context.Context, userID int64, limit int) ([]*entity.Favorite, error) + Delete(ctx context.Context, userID, id int64) error +} + +type Messages interface { + GetByID(ctx context.Context, id int64) (*msgentity.Message, error) +} + +type Members interface { + GetMember(ctx context.Context, conversationID, userID int64) (*conventity.Member, error) +} + +type Objects interface { + Copy(ctx context.Context, src, dst string) error + Delete(ctx context.Context, key string) error +} + +type IDGen interface { + NextID() (int64, error) +} + +// Service is the favorite use-case layer (HTTP and TCP share it). +type Service struct { + repo Repository + msgs Messages + members Members + objects Objects + ids IDGen +} + +func NewService(repo Repository, msgs Messages, members Members, objects Objects, ids IDGen) *Service { + return &Service{repo: repo, msgs: msgs, members: members, objects: objects, ids: ids} +} + +func (s *Service) ready() bool { + return s != nil && s.repo != nil && s.msgs != nil && s.members != nil && s.ids != nil +} diff --git a/internal/module/favorite/model/favorite.go b/internal/module/favorite/model/favorite.go new file mode 100644 index 0000000..247860a --- /dev/null +++ b/internal/module/favorite/model/favorite.go @@ -0,0 +1,44 @@ +package model + +import ( + "time" + + "spark-server/internal/module/favorite/entity" +) + +// Favorite maps table favorites. +type Favorite struct { + ID int64 `gorm:"column:id;primaryKey;comment:Snowflake 收藏 id"` + UserID int64 `gorm:"column:user_id;comment:收藏者 user_id"` + MsgID int64 `gorm:"column:msg_id;comment:原消息 Snowflake id,仅作来源;撤回不影响收藏"` + ConversationID int64 `gorm:"column:conversation_id;comment:原会话 id,仅作来源"` + MessageType int16 `gorm:"column:message_type;comment:消息类型,与 messages.message_type 一致"` + Content []byte `gorm:"column:content;type:blob;comment:收藏快照(JSON 字节);媒体 key 已复制到 perm/fav/"` + CreatedAt time.Time `gorm:"column:created_at;comment:收藏时间"` +} + +func (Favorite) TableName() string { return "favorites" } + +func (r *Favorite) Entity() *entity.Favorite { + if r == nil { + return nil + } + return &entity.Favorite{ + ID: r.ID, UserID: r.UserID, MsgID: r.MsgID, ConversationID: r.ConversationID, + MessageType: r.MessageType, Content: r.Content, CreatedAtMS: r.CreatedAt.UnixMilli(), + } +} + +func fromEntity(f *entity.Favorite) *Favorite { + if f == nil { + return nil + } + row := &Favorite{ + ID: f.ID, UserID: f.UserID, MsgID: f.MsgID, ConversationID: f.ConversationID, + MessageType: f.MessageType, Content: f.Content, + } + if f.CreatedAtMS > 0 { + row.CreatedAt = time.UnixMilli(f.CreatedAtMS) + } + return row +} diff --git a/internal/module/favorite/model/memory.go b/internal/module/favorite/model/memory.go new file mode 100644 index 0000000..6c76f46 --- /dev/null +++ b/internal/module/favorite/model/memory.go @@ -0,0 +1,104 @@ +package model + +import ( + "context" + "sort" + "sync" + + "spark-server/internal/module/favorite/entity" + "spark-server/internal/pkg/errs" +) + +type userMsgKey struct { + UserID int64 + MsgID int64 +} + +// MemoryRepo is an in-process favorite store for tests and empty-DSN bring-up. +type MemoryRepo struct { + mu sync.Mutex + byID map[int64]*entity.Favorite + byPair map[userMsgKey]int64 +} + +func NewMemoryRepo() *MemoryRepo { + return &MemoryRepo{byID: make(map[int64]*entity.Favorite), byPair: make(map[userMsgKey]int64)} +} + +func (m *MemoryRepo) Get(_ context.Context, id int64) (*entity.Favorite, error) { + m.mu.Lock() + defer m.mu.Unlock() + row, ok := m.byID[id] + if !ok { + return nil, errs.ErrNotFound + } + return cloneFav(row), nil +} + +func (m *MemoryRepo) GetByUserMsg(_ context.Context, userID, msgID int64) (*entity.Favorite, error) { + m.mu.Lock() + defer m.mu.Unlock() + id, ok := m.byPair[userMsgKey{UserID: userID, MsgID: msgID}] + if !ok { + return nil, errs.ErrNotFound + } + return cloneFav(m.byID[id]), nil +} + +func (m *MemoryRepo) Create(_ context.Context, fav *entity.Favorite) error { + if fav == nil || fav.ID <= 0 || fav.UserID <= 0 { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + key := userMsgKey{UserID: fav.UserID, MsgID: fav.MsgID} + if _, ok := m.byPair[key]; ok { + return errs.ErrDuplicate + } + if _, ok := m.byID[fav.ID]; ok { + return errs.ErrDuplicate + } + m.byID[fav.ID] = cloneFav(fav) + m.byPair[key] = fav.ID + return nil +} + +func (m *MemoryRepo) ListByUser(_ context.Context, userID int64, limit int) ([]*entity.Favorite, error) { + m.mu.Lock() + defer m.mu.Unlock() + limit = entity.ClampLimit(limit) + out := make([]*entity.Favorite, 0) + for _, row := range m.byID { + if row.UserID == userID { + out = append(out, cloneFav(row)) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (m *MemoryRepo) Delete(_ context.Context, userID, id int64) error { + m.mu.Lock() + defer m.mu.Unlock() + row, ok := m.byID[id] + if !ok || row.UserID != userID { + return errs.ErrNotFound + } + delete(m.byID, id) + delete(m.byPair, userMsgKey{UserID: row.UserID, MsgID: row.MsgID}) + return nil +} + +func cloneFav(f *entity.Favorite) *entity.Favorite { + if f == nil { + return nil + } + cp := *f + if f.Content != nil { + cp.Content = append([]byte(nil), f.Content...) + } + return &cp +} diff --git a/internal/module/favorite/model/repo.go b/internal/module/favorite/model/repo.go new file mode 100644 index 0000000..a3a9585 --- /dev/null +++ b/internal/module/favorite/model/repo.go @@ -0,0 +1,114 @@ +package model + +import ( + "context" + "errors" + "time" + + mysqldriver "github.com/go-sql-driver/mysql" + "gorm.io/gorm" + + "spark-server/internal/module/favorite/entity" + "spark-server/internal/pkg/errs" +) + +// Repo is MySQL persistence for favorites. +type Repo struct { + gdb *gorm.DB +} + +func NewRepo(gdb *gorm.DB) *Repo { + return &Repo{gdb: gdb} +} + +func (m *Repo) session(ctx context.Context) (*gorm.DB, error) { + if m == nil || m.gdb == nil { + return nil, errs.ErrAvailable + } + return m.gdb.WithContext(ctx), nil +} + +func mapDBErr(err error) error { + if err == nil { + return nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return errs.ErrNotFound + } + var me *mysqldriver.MySQLError + if errors.As(err, &me) && me.Number == 1062 { + return errs.ErrDuplicate + } + return errs.ErrInner.WithError("favorite-mysql", err) +} + +func (m *Repo) Get(ctx context.Context, id int64) (*entity.Favorite, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row Favorite + if err := db.Where("id = ?", id).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.Entity(), nil +} + +func (m *Repo) GetByUserMsg(ctx context.Context, userID, msgID int64) (*entity.Favorite, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row Favorite + if err := db.Where("user_id = ? AND msg_id = ?", userID, msgID).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.Entity(), nil +} + +func (m *Repo) Create(ctx context.Context, fav *entity.Favorite) error { + if fav == nil || fav.ID <= 0 || fav.UserID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + row := fromEntity(fav) + if row.CreatedAt.IsZero() { + row.CreatedAt = time.Now() + } + return mapDBErr(db.Create(row).Error) +} + +func (m *Repo) ListByUser(ctx context.Context, userID int64, limit int) ([]*entity.Favorite, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + limit = entity.ClampLimit(limit) + var rows []Favorite + if err := db.Where("user_id = ?", userID).Order("id DESC").Limit(limit).Find(&rows).Error; err != nil { + return nil, mapDBErr(err) + } + out := make([]*entity.Favorite, 0, len(rows)) + for i := range rows { + out = append(out, rows[i].Entity()) + } + return out, nil +} + +func (m *Repo) Delete(ctx context.Context, userID, id int64) error { + db, err := m.session(ctx) + if err != nil { + return err + } + res := db.Where("id = ? AND user_id = ?", id, userID).Delete(&Favorite{}) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return nil +} diff --git a/internal/module/friend/entity/accept.go b/internal/module/friend/entity/accept.go new file mode 100644 index 0000000..a596b38 --- /dev/null +++ b/internal/module/friend/entity/accept.go @@ -0,0 +1,8 @@ +package entity + +// AcceptParams is the transactional write when two users become friends. +type AcceptParams struct { + Relation *Relation + Requests []int64 + Remarks []Remark +} diff --git a/internal/module/friend/entity/block.go b/internal/module/friend/entity/block.go new file mode 100644 index 0000000..4dcf0fd --- /dev/null +++ b/internal/module/friend/entity/block.go @@ -0,0 +1,10 @@ +package entity + +import "time" + +// Block is unidirectional: UserID blocked BlockedID. +type Block struct { + UserID int64 + BlockedID int64 + CreatedAt time.Time +} diff --git a/internal/module/friend/entity/pair.go b/internal/module/friend/entity/pair.go new file mode 100644 index 0000000..016f8dc --- /dev/null +++ b/internal/module/friend/entity/pair.go @@ -0,0 +1,21 @@ +package entity + +import "time" + +func OrderUsers(a, b int64) (small, big int64) { + if a <= b { + return a, b + } + return b, a +} + +func SameUser(a, b int64) bool { + return a > 0 && a == b +} + +func UnixMS(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.UnixMilli() +} diff --git a/internal/module/friend/entity/relation.go b/internal/module/friend/entity/relation.go new file mode 100644 index 0000000..41c3bd7 --- /dev/null +++ b/internal/module/friend/entity/relation.go @@ -0,0 +1,60 @@ +package entity + +import "time" + +const ( + RelationNone = 0 + RelationFriend = 1 + RelationBlock = 2 +) + +const ( + ListDefault = 20 + ListMax = 100 +) + +// Relation is a bidirectional friendship. Natural key (user_small, user_big). +type Relation struct { + UserSmall int64 + UserBig int64 + CreatedAt time.Time +} + +func NewRelation(a, b int64, at time.Time) *Relation { + small, big := OrderUsers(a, b) + return &Relation{UserSmall: small, UserBig: big, CreatedAt: at} +} + +func (r *Relation) Other(me int64) int64 { + if r == nil { + return 0 + } + if r.UserSmall == me { + return r.UserBig + } + if r.UserBig == me { + return r.UserSmall + } + return 0 +} + +func ClampLimit(limit int) int { + if limit <= 0 { + return ListDefault + } + if limit > ListMax { + return ListMax + } + return limit +} + +// ViewStatus is the viewer-centric relation: friend, blocked-by-me, or none. +func ViewStatus(friend, blockedByMe bool) int { + if blockedByMe { + return RelationBlock + } + if friend { + return RelationFriend + } + return RelationNone +} diff --git a/internal/module/friend/entity/remark.go b/internal/module/friend/entity/remark.go new file mode 100644 index 0000000..04b8226 --- /dev/null +++ b/internal/module/friend/entity/remark.go @@ -0,0 +1,11 @@ +package entity + +import "time" + +// Remark is viewer-only (UserID's note on FriendID). +type Remark struct { + UserID int64 + FriendID int64 + Text string + UpdatedAt time.Time +} diff --git a/internal/module/friend/entity/report.go b/internal/module/friend/entity/report.go new file mode 100644 index 0000000..5fa7b7b --- /dev/null +++ b/internal/module/friend/entity/report.go @@ -0,0 +1,26 @@ +package entity + +import "time" + +const ( + ReportSpam = 1 + ReportHarassment = 2 + ReportFake = 3 + ReportOther = 4 +) + +const ReportDetailMax = 512 + +// Report is a complaint about TargetID by UserID. +type Report struct { + ID int64 + UserID int64 + TargetID int64 + Reason int + Detail string + CreatedAt time.Time +} + +func ValidReportReason(r int) bool { + return r >= ReportSpam && r <= ReportOther +} diff --git a/internal/module/friend/entity/request.go b/internal/module/friend/entity/request.go new file mode 100644 index 0000000..45383e6 --- /dev/null +++ b/internal/module/friend/entity/request.go @@ -0,0 +1,37 @@ +package entity + +import "time" + +const ( + RequestPending = 1 + RequestAccepted = 2 + RequestRejected = 3 +) + +const ( + SourceQRCode = 1 + SourceEmail = 2 + SourceSparkID = 3 +) + +const ( + ReasonMax = 128 + RemarkMax = 64 +) + +// Request is one from→to application. Repeats update this row; they do not insert another. +type Request struct { + ID int64 + FromUser int64 + ToUser int64 + Source int + Reason string + Remark string + Status int + CreatedAt time.Time + UpdatedAt time.Time +} + +func ValidSource(src int) bool { + return src == SourceQRCode || src == SourceEmail || src == SourceSparkID +} diff --git a/internal/module/friend/entity/system.go b/internal/module/friend/entity/system.go new file mode 100644 index 0000000..c3f49b8 --- /dev/null +++ b/internal/module/friend/entity/system.go @@ -0,0 +1,4 @@ +package entity + +// SystemKindBecameFriends is messages.content system.kind after both sides become friends. +const SystemKindBecameFriends = "friend.became" diff --git a/internal/module/friend/logic/accept.go b/internal/module/friend/logic/accept.go new file mode 100644 index 0000000..b10e77b --- /dev/null +++ b/internal/module/friend/logic/accept.go @@ -0,0 +1,73 @@ +package logic + +import ( + "context" + "errors" + "strings" + "time" + + "spark-server/internal/module/friend/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" +) + +// AcceptInput creates a bidirectional friendship from a pending incoming request. +type AcceptInput struct { + RequestID int64 `json:"request_id" validate:"required,gt=0" label:"field.request_id" errmsg:"required:validate.friend.request_required"` + Remark string `json:"remark" validate:"omitempty,max=64" label:"field.remark" errmsg:"max:validate.friend.remark_invalid"` +} + +type AcceptResult struct { + UID int64 `json:"uid"` +} + +func (s *Service) Accept(ctx context.Context, userID int64, in AcceptInput) (*AcceptResult, error) { + if !s.ready() || userID <= 0 { + return nil, errs.ErrUnauthorized + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return nil, err + } + req, err := s.repo.GetRequest(ctx, in.RequestID) + if err != nil { + return nil, err + } + lang := reqctx.Lang(ctx) + if req.ToUser != userID || req.Status != entity.RequestPending { + return nil, errs.ErrForbidden.WithMsg(i18n.T(lang, i18n.KeyFriendRequestInvalid, nil)) + } + if err := s.canApply(ctx, userID, req.FromUser); err != nil { + if !errors.Is(err, errs.ErrDuplicate) { + return nil, err + } + } + if err := s.becomeFriends(ctx, req, userID, strings.TrimSpace(in.Remark)); err != nil { + return nil, err + } + return &AcceptResult{UID: req.FromUser}, nil +} + +func (s *Service) becomeFriends(ctx context.Context, req *entity.Request, accepter int64, accepterRemark string) error { + now := time.Now() + rel := entity.NewRelation(req.FromUser, req.ToUser, now) + ids := []int64{req.ID} + if reverse, err := s.repo.GetRequestByPair(ctx, accepter, req.FromUser); err == nil && reverse.ID != req.ID { + ids = append(ids, reverse.ID) + } + remarks := make([]entity.Remark, 0, 2) + if strings.TrimSpace(req.Remark) != "" { + remarks = append(remarks, entity.Remark{UserID: req.FromUser, FriendID: req.ToUser, Text: req.Remark, UpdatedAt: now}) + } + if accepterRemark != "" { + remarks = append(remarks, entity.Remark{UserID: accepter, FriendID: req.FromUser, Text: accepterRemark, UpdatedAt: now}) + } + if err := s.repo.AcceptFriend(ctx, entity.AcceptParams{Relation: rel, Requests: ids, Remarks: remarks}); err != nil { + return err + } + if s.greeter != nil { + _ = s.greeter.BecameFriends(ctx, req.FromUser, req.ToUser) + } + return nil +} diff --git a/internal/module/friend/logic/apply.go b/internal/module/friend/logic/apply.go new file mode 100644 index 0000000..b1adcc7 --- /dev/null +++ b/internal/module/friend/logic/apply.go @@ -0,0 +1,183 @@ +package logic + +import ( + "context" + "errors" + "strings" + "time" + + "spark-server/internal/module/friend/entity" + userentity "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" +) + +// ApplyInput starts a friend request. Exactly one of uid / email / spark_id / qrcode. +type ApplyInput struct { + UID int64 `json:"uid" validate:"omitempty,gt=0" label:"field.uid"` + Email string `json:"email" validate:"omitempty,email" label:"field.email" errmsg:"email:validate.login.email_invalid"` + SparkID string `json:"spark_id" validate:"omitempty,min=6,max=32" label:"field.spark_id" errmsg:"min:validate.user.spark_id_invalid;max:validate.user.spark_id_invalid"` + QRCode string `json:"qrcode" validate:"omitempty,max=64" label:"field.qrcode"` + Reason string `json:"reason" validate:"omitempty,max=128" label:"field.reason" errmsg:"max:validate.friend.reason_invalid"` + Remark string `json:"remark" validate:"omitempty,max=64" label:"field.remark" errmsg:"max:validate.friend.remark_invalid"` +} + +// ApplyResult is returned after applying or auto-accepting a reverse request. +type ApplyResult struct { + Status string `json:"status"` + RequestID int64 `json:"request_id,omitempty"` + UID int64 `json:"uid"` +} + +func (s *Service) Apply(ctx context.Context, fromUserID int64, in *ApplyInput) (*ApplyResult, error) { + if !s.ready() || fromUserID <= 0 { + return nil, errs.ErrUnauthorized + } + if in == nil { + return nil, errs.ErrInvalidArgument + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return nil, err + } + target, source, err := s.resolveTarget(ctx, in) + if err != nil { + return nil, err + } + if err := s.canApply(ctx, fromUserID, target.ID); err != nil { + return nil, err + } + if reverse, err := s.repo.GetRequestByPair(ctx, target.ID, fromUserID); err == nil && reverse.Status == entity.RequestPending { + if err := s.becomeFriends(ctx, reverse, fromUserID, strings.TrimSpace(in.Remark)); err != nil { + return nil, err + } + return &ApplyResult{Status: "accepted", UID: target.ID}, nil + } else if err != nil && !errors.Is(err, errs.ErrNotFound) { + return nil, err + } + req, err := s.upsertPending(ctx, fromUserID, target.ID, source, in) + if err != nil { + return nil, err + } + return &ApplyResult{Status: "pending", RequestID: req.ID, UID: target.ID}, nil +} + +func (s *Service) resolveTarget(ctx context.Context, in *ApplyInput) (*userentity.User, int, error) { + lang := reqctx.Lang(ctx) + n := 0 + if in.UID > 0 { + n++ + } + if strings.TrimSpace(in.Email) != "" { + n++ + } + if strings.TrimSpace(in.SparkID) != "" { + n++ + } + if strings.TrimSpace(in.QRCode) != "" { + n++ + } + if n != 1 { + return nil, 0, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyFriendTargetRequired, nil)) + } + switch { + case in.UID > 0: + u, err := s.activeUser(ctx, in.UID) + return u, entity.SourceSparkID, err + case strings.TrimSpace(in.Email) != "": + u, err := s.users.GetByEmail(ctx, userentity.NormalizeEmail(in.Email)) + return s.ensureActive(ctx, u, err, entity.SourceEmail) + case strings.TrimSpace(in.SparkID) != "": + u, err := s.users.GetBySparkID(ctx, strings.TrimSpace(in.SparkID)) + return s.ensureActive(ctx, u, err, entity.SourceSparkID) + default: + raw := strings.TrimSpace(in.QRCode) + if !userentity.ValidQRCode(raw) { + return nil, 0, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyFriendQRInvalid, nil)) + } + u, err := s.users.GetByQRCode(ctx, raw) + return s.ensureActive(ctx, u, err, entity.SourceQRCode) + } +} + +func (s *Service) ensureActive(_ context.Context, u *userentity.User, err error, source int) (*userentity.User, int, error) { + if err != nil { + if errors.Is(err, errs.ErrNotFound) { + return nil, 0, errs.ErrNotFound + } + return nil, 0, err + } + if u == nil || u.Status != userentity.StatusActive { + return nil, 0, errs.ErrNotFound + } + return u, source, nil +} + +func (s *Service) activeUser(ctx context.Context, id int64) (*userentity.User, error) { + u, err := s.users.Get(ctx, id) + if err != nil { + return nil, err + } + if u == nil || u.Status != userentity.StatusActive { + return nil, errs.ErrNotFound + } + return u, nil +} + +func (s *Service) canApply(ctx context.Context, from, to int64) error { + lang := reqctx.Lang(ctx) + if entity.SameUser(from, to) { + return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyFriendSelf, nil)) + } + blockedMe, err := s.repo.IsBlocked(ctx, from, to) + if err != nil { + return err + } + if blockedMe { + return errs.ErrForbidden.WithMsg(i18n.T(lang, i18n.KeyFriendBlocked, nil)) + } + blockedBy, err := s.repo.IsBlocked(ctx, to, from) + if err != nil { + return err + } + if blockedBy { + return errs.ErrNotFound + } + if _, err := s.repo.GetRelation(ctx, from, to); err == nil { + return errs.ErrDuplicate.WithMsg(i18n.T(lang, i18n.KeyFriendAlready, nil)) + } else if !errors.Is(err, errs.ErrNotFound) { + return err + } + return nil +} + +func (s *Service) upsertPending(ctx context.Context, from, to int64, source int, in *ApplyInput) (*entity.Request, error) { + now := time.Now() + req := &entity.Request{ + FromUser: from, ToUser: to, Source: source, + Reason: strings.TrimSpace(in.Reason), Remark: strings.TrimSpace(in.Remark), + Status: entity.RequestPending, CreatedAt: now, UpdatedAt: now, + } + existing, err := s.repo.GetRequestByPair(ctx, from, to) + switch { + case err == nil: + req.ID = existing.ID + req.CreatedAt = existing.CreatedAt + if req.Remark == "" { + req.Remark = existing.Remark + } + case errors.Is(err, errs.ErrNotFound): + id, err := s.ids.NextID() + if err != nil { + return nil, err + } + req.ID = id + default: + return nil, err + } + if err := s.repo.UpsertRequest(ctx, req); err != nil { + return nil, err + } + return req, nil +} diff --git a/internal/module/friend/logic/block.go b/internal/module/friend/logic/block.go new file mode 100644 index 0000000..8044de1 --- /dev/null +++ b/internal/module/friend/logic/block.go @@ -0,0 +1,54 @@ +package logic + +import ( + "context" + "errors" + "time" + + "spark-server/internal/module/friend/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" +) + +func (s *Service) Block(ctx context.Context, userID, targetID int64) error { + if !s.ready() || userID <= 0 { + return errs.ErrUnauthorized + } + if targetID <= 0 { + return errs.ErrInvalidArgument + } + if entity.SameUser(userID, targetID) { + return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendSelf, nil)) + } + if _, err := s.activeUser(ctx, targetID); err != nil { + return err + } + return s.repo.CreateBlock(ctx, &entity.Block{UserID: userID, BlockedID: targetID, CreatedAt: time.Now()}) +} + +func (s *Service) Unblock(ctx context.Context, userID, targetID int64) error { + if !s.ready() || userID <= 0 { + return errs.ErrUnauthorized + } + if targetID <= 0 { + return errs.ErrInvalidArgument + } + return s.repo.DeleteBlock(ctx, userID, targetID) +} + +func (s *Service) Delete(ctx context.Context, userID, friendID int64) error { + if !s.ready() || userID <= 0 { + return errs.ErrUnauthorized + } + if friendID <= 0 { + return errs.ErrInvalidArgument + } + if err := s.repo.DeleteFriend(ctx, userID, friendID); err != nil { + if errors.Is(err, errs.ErrNotFound) { + return errs.ErrNotFound.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendNotFriend, nil)) + } + return err + } + return nil +} diff --git a/internal/module/friend/logic/cansend.go b/internal/module/friend/logic/cansend.go new file mode 100644 index 0000000..9fc7ad9 --- /dev/null +++ b/internal/module/friend/logic/cansend.go @@ -0,0 +1,31 @@ +package logic + +import ( + "context" + "errors" + + "spark-server/internal/pkg/errs" +) + +func (s *Service) CanSend(ctx context.Context, fromUserID, toUserID int64) error { + if !s.ready() || fromUserID <= 0 || toUserID <= 0 { + return errs.ErrForbidden + } + if fromUserID == toUserID { + return errs.ErrForbidden + } + if _, err := s.repo.GetRelation(ctx, fromUserID, toUserID); err != nil { + if errors.Is(err, errs.ErrNotFound) { + return errs.ErrForbidden + } + return err + } + blocked, err := s.repo.BlockedEither(ctx, fromUserID, toUserID) + if err != nil { + return err + } + if blocked { + return errs.ErrForbidden + } + return nil +} diff --git a/internal/module/friend/logic/friend_test.go b/internal/module/friend/logic/friend_test.go new file mode 100644 index 0000000..09ca6aa --- /dev/null +++ b/internal/module/friend/logic/friend_test.go @@ -0,0 +1,220 @@ +package logic + +import ( + "context" + "errors" + "testing" + + "spark-server/internal/infrastructure/id" + convlogic "spark-server/internal/module/conversation/logic" + convmodel "spark-server/internal/module/conversation/model" + delivermodel "spark-server/internal/module/deliver/model" + "spark-server/internal/module/friend/entity" + friendmodel "spark-server/internal/module/friend/model" + msgentity "spark-server/internal/module/message/entity" + msgmodel "spark-server/internal/module/message/model" + userentity "spark-server/internal/module/user/entity" + userlogic "spark-server/internal/module/user/logic" + usermodel "spark-server/internal/module/user/model" + "spark-server/internal/pkg/errs" +) + +type captureGreeter struct { + n int + a, b int64 +} + +func (c *captureGreeter) BecameFriends(_ context.Context, a, b int64) error { + c.n++ + c.a, c.b = a, b + return nil +} + +func testFriends(t *testing.T) (*Service, *userlogic.Service, *captureGreeter) { + t.Helper() + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + g := &captureGreeter{} + return NewService(friendmodel.NewMemoryRepo(), users, ids, g), users, g +} + +func twoUsers(t *testing.T, users *userlogic.Service) (*userentity.User, *userentity.User) { + t.Helper() + ctx := t.Context() + a, err := users.AuthenticateEmail(ctx, "test01@spark.com", "1", &userentity.LoginDevice{DeviceID: 1, DeviceType: userentity.DeviceIOS}) + if err != nil { + t.Fatal(err) + } + b, err := users.AuthenticateEmail(ctx, "test02@spark.com", "1", &userentity.LoginDevice{DeviceID: 2, DeviceType: userentity.DeviceWeb}) + if err != nil { + t.Fatal(err) + } + return a, b +} + +func TestApplyByEmailSparkIDQRAndDedupe(t *testing.T) { + svc, users, _ := testFriends(t) + ctx := t.Context() + a, b := twoUsers(t, users) + first, err := svc.Apply(ctx, a.ID, &ApplyInput{Email: "test02@spark.com", Reason: "hi", Remark: "work"}) + if err != nil || first.Status != "pending" || first.RequestID == 0 { + t.Fatalf("email apply %+v %v", first, err) + } + second, err := svc.Apply(ctx, a.ID, &ApplyInput{SparkID: b.SparkID, Reason: "again"}) + if err != nil { + t.Fatal(err) + } + if second.RequestID != first.RequestID { + t.Fatalf("dedupe %d %d", first.RequestID, second.RequestID) + } + third, err := svc.Apply(ctx, a.ID, &ApplyInput{QRCode: b.QRCode, Reason: "qr"}) + if err != nil || third.RequestID != first.RequestID { + t.Fatalf("qr %+v %v", third, err) + } + list, err := svc.ListRequests(ctx, b.ID, 20) + if err != nil || len(list.Items) != 1 || list.Items[0].Reason != "qr" || list.Items[0].From.UID != a.ID { + t.Fatalf("inbox %+v %v", list, err) + } +} + +func TestAcceptBecomesFriendsAndGreeting(t *testing.T) { + svc, users, g := testFriends(t) + ctx := t.Context() + a, b := twoUsers(t, users) + applied, err := svc.Apply(ctx, a.ID, &ApplyInput{Email: "test02@spark.com", Remark: "from-a"}) + if err != nil { + t.Fatal(err) + } + got, err := svc.Accept(ctx, b.ID, AcceptInput{RequestID: applied.RequestID, Remark: "from-b"}) + if err != nil || got.UID != a.ID { + t.Fatalf("accept %+v %v", got, err) + } + if g.n != 1 { + t.Fatalf("greeting %d", g.n) + } + if err := svc.CanSend(ctx, a.ID, b.ID); err != nil { + t.Fatal(err) + } + view, err := svc.Get(ctx, a.ID, b.ID) + if err != nil || view.Remark != "from-a" || view.Relation != entity.RelationFriend { + t.Fatalf("a view %+v %v", view, err) + } + view, err = svc.Get(ctx, b.ID, a.ID) + if err != nil || view.Remark != "from-b" { + t.Fatalf("b view %+v %v", view, err) + } + if _, err := svc.Get(ctx, a.ID, a.ID); !errors.Is(err, errs.ErrNotFound) { + t.Fatalf("self %v", err) + } +} + +func TestMutualApplyAutoAccept(t *testing.T) { + svc, users, g := testFriends(t) + ctx := t.Context() + a, b := twoUsers(t, users) + if _, err := svc.Apply(ctx, a.ID, &ApplyInput{UID: b.ID, Remark: "a-note"}); err != nil { + t.Fatal(err) + } + out, err := svc.Apply(ctx, b.ID, &ApplyInput{Email: "test01@spark.com", Remark: "b-note"}) + if err != nil || out.Status != "accepted" { + t.Fatalf("%+v %v", out, err) + } + if g.n != 1 { + t.Fatalf("greeting %d", g.n) + } + if err := svc.CanSend(ctx, b.ID, a.ID); err != nil { + t.Fatal(err) + } +} + +func TestNoUnidirectionalDeleteAndBlock(t *testing.T) { + svc, users, _ := testFriends(t) + ctx := t.Context() + a, b := twoUsers(t, users) + applied, _ := svc.Apply(ctx, a.ID, &ApplyInput{Email: "test02@spark.com"}) + if _, err := svc.Accept(ctx, b.ID, AcceptInput{RequestID: applied.RequestID}); err != nil { + t.Fatal(err) + } + if err := svc.Delete(ctx, a.ID, b.ID); err != nil { + t.Fatal(err) + } + if err := svc.CanSend(ctx, b.ID, a.ID); !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("after delete %v", err) + } + applied, _ = svc.Apply(ctx, a.ID, &ApplyInput{Email: "test02@spark.com"}) + if _, err := svc.Accept(ctx, b.ID, AcceptInput{RequestID: applied.RequestID}); err != nil { + t.Fatal(err) + } + if err := svc.Block(ctx, a.ID, b.ID); err != nil { + t.Fatal(err) + } + if err := svc.CanSend(ctx, a.ID, b.ID); !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("blocked send %v", err) + } + if _, err := svc.Apply(ctx, a.ID, &ApplyInput{Email: "test02@spark.com"}); !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("apply blocked %v", err) + } + if _, err := svc.Apply(ctx, b.ID, &ApplyInput{Email: "test01@spark.com"}); !errors.Is(err, errs.ErrNotFound) { + t.Fatalf("apply blocked-by %v", err) + } +} + +func TestRemarkReportLookup(t *testing.T) { + svc, users, _ := testFriends(t) + ctx := t.Context() + a, b := twoUsers(t, users) + look, err := svc.Lookup(ctx, a.ID, LookupInput{SparkID: b.SparkID}) + if err != nil || look.UID != b.ID || look.Relation != entity.RelationNone { + t.Fatalf("lookup %+v %v", look, err) + } + applied, _ := svc.Apply(ctx, a.ID, &ApplyInput{SparkID: b.SparkID}) + if _, err := svc.Accept(ctx, b.ID, AcceptInput{RequestID: applied.RequestID}); err != nil { + t.Fatal(err) + } + if err := svc.SetRemark(ctx, a.ID, b.ID, RemarkInput{Remark: "boss"}); err != nil { + t.Fatal(err) + } + view, err := svc.Get(ctx, a.ID, b.ID) + if err != nil || view.Remark != "boss" { + t.Fatalf("remark %+v %v", view, err) + } + if err := svc.Report(ctx, a.ID, b.ID, ReportInput{Reason: entity.ReportSpam, Detail: "ads"}); err != nil { + t.Fatal(err) + } + if _, err := svc.Apply(ctx, a.ID, &ApplyInput{Email: "test02@spark.com"}); !errors.Is(err, errs.ErrDuplicate) { + t.Fatalf("already %v", err) + } +} + +func TestSystemGreeterWritesMessage(t *testing.T) { + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + msgs := msgmodel.NewMemoryRepo() + chats := convlogic.NewP2PService(convmodel.NewMemoryP2P(), ids) + g := NewGreeter(chats, msgs, ids, delivermodel.NewMemoryQueue()) + if err := g.BecameFriends(t.Context(), 11, 22); err != nil { + t.Fatal(err) + } + convID, err := chats.EnsureP2P(t.Context(), 11, 22) + if err != nil || convID == 0 { + t.Fatalf("conv %d %v", convID, err) + } + found := false + for _, msg := range msgs.All() { + if msg.ConversationID == convID && msg.Type == msgentity.TypeSystem { + found = true + payload, err := msg.Payload() + if err != nil || payload.System == nil || payload.System.Kind != entity.SystemKindBecameFriends { + t.Fatalf("payload %+v %v", payload, err) + } + } + } + if !found { + t.Fatal("missing system message") + } +} diff --git a/internal/module/friend/logic/friends.go b/internal/module/friend/logic/friends.go new file mode 100644 index 0000000..d116dc3 --- /dev/null +++ b/internal/module/friend/logic/friends.go @@ -0,0 +1,22 @@ +package logic + +import ( + "context" + "errors" + + "spark-server/internal/pkg/errs" +) + +func (s *Service) AreFriends(ctx context.Context, a, b int64) (bool, error) { + if !s.ready() || a <= 0 || b <= 0 || a == b { + return false, nil + } + _, err := s.repo.GetRelation(ctx, a, b) + if err == nil { + return true, nil + } + if errors.Is(err, errs.ErrNotFound) { + return false, nil + } + return false, err +} diff --git a/internal/module/friend/logic/greeting.go b/internal/module/friend/logic/greeting.go new file mode 100644 index 0000000..105b52d --- /dev/null +++ b/internal/module/friend/logic/greeting.go @@ -0,0 +1,94 @@ +package logic + +import ( + "context" + "encoding/json" + "fmt" + "time" + + deliverentity "spark-server/internal/module/deliver/entity" + friendentity "spark-server/internal/module/friend/entity" + msgentity "spark-server/internal/module/message/entity" + "spark-server/internal/pkg/reply" +) + +const cmdMessage uint16 = 6 + +// DeliverQueue is Redis deliver:queue Push (C5). +type DeliverQueue interface { + Push(ctx context.Context, job *deliverentity.Job) error +} + +// SystemGreeter inserts a type-9 system message into the P2P conversation and enqueues deliver. +type SystemGreeter struct { + chats P2PChats + msgs Messages + ids IDGen + queue DeliverQueue +} + +func NewGreeter(chats P2PChats, msgs Messages, ids IDGen, queue DeliverQueue) *SystemGreeter { + return &SystemGreeter{chats: chats, msgs: msgs, ids: ids, queue: queue} +} + +func (g *SystemGreeter) BecameFriends(ctx context.Context, userA, userB int64) error { + if g == nil || g.chats == nil || g.msgs == nil || g.ids == nil { + return nil + } + convID, err := g.chats.EnsureP2P(ctx, userA, userB) + if err != nil { + return err + } + msgID, err := g.ids.NextID() + if err != nil { + return err + } + now := time.Now() + content := msgentity.NewSystem(&msgentity.System{Kind: friendentity.SystemKindBecameFriends}) + raw, err := content.Marshal() + if err != nil { + return err + } + msg := &msgentity.Message{ + ID: msgID, + ConversationID: convID, + SenderID: 0, + ClientMsgID: fmt.Sprintf("sys:friend:%d", msgID), + Type: msgentity.TypeSystem, + Content: raw, + Status: msgentity.StatusNormal, + CreatedAtMS: now.UnixMilli(), + } + if err := g.msgs.Create(ctx, msg); err != nil { + return err + } + if err := g.chats.UpdateLastMsg(ctx, convID, msgID, now.UnixMilli()); err != nil { + return err + } + if g.queue == nil { + return nil + } + return g.queue.Push(ctx, &deliverentity.Job{ + MsgID: msgID, + Cmd: cmdMessage, + ConversationID: convID, + ToUserIDs: []int64{userA, userB}, + Payload: reply.MustMarshal(reply.OK(pushBodyOf(msg))), + }) +} + +type pushBody struct { + MsgID int64 `json:"msg_id"` + ConversationID int64 `json:"conversation_id"` + SenderID int64 `json:"sender_id"` + MessageType int16 `json:"message_type"` + Content json.RawMessage `json:"content"` + CreatedAt int64 `json:"created_at"` +} + +func pushBodyOf(m *msgentity.Message) pushBody { + return pushBody{ + MsgID: m.ID, ConversationID: m.ConversationID, SenderID: m.SenderID, + MessageType: int16(m.Type), Content: m.Content, CreatedAt: m.CreatedAtMS, + } +} diff --git a/internal/module/friend/logic/lookup.go b/internal/module/friend/logic/lookup.go new file mode 100644 index 0000000..248a810 --- /dev/null +++ b/internal/module/friend/logic/lookup.go @@ -0,0 +1,64 @@ +package logic + +import ( + "context" + "errors" + + "spark-server/internal/module/friend/entity" + "spark-server/internal/pkg/errs" +) + +// LookupInput finds a user to add. Exactly one identifier. +type LookupInput struct { + UID int64 + Email string + SparkID string + QRCode string +} + +func (s *Service) Lookup(ctx context.Context, userID int64, in LookupInput) (*FriendView, error) { + if !s.ready() || userID <= 0 { + return nil, errs.ErrUnauthorized + } + target, _, err := s.resolveTarget(ctx, &ApplyInput{ + UID: in.UID, Email: in.Email, SparkID: in.SparkID, QRCode: in.QRCode, + }) + if err != nil { + return nil, err + } + blockedBy, err := s.repo.IsBlocked(ctx, target.ID, userID) + if err != nil { + return nil, err + } + if blockedBy { + return nil, errs.ErrNotFound + } + friend := false + if _, err := s.repo.GetRelation(ctx, userID, target.ID); err == nil { + friend = true + } else if !errors.Is(err, errs.ErrNotFound) { + return nil, err + } + blockedMe, err := s.repo.IsBlocked(ctx, userID, target.ID) + if err != nil { + return nil, err + } + remark := "" + if friend { + remark, err = s.repo.GetRemark(ctx, userID, target.ID) + if err != nil { + return nil, err + } + } + view := &FriendView{ + UserCard: cardOf(target), + Remark: remark, + Relation: entity.ViewStatus(friend, blockedMe), + } + if friend { + if rel, err := s.repo.GetRelation(ctx, userID, target.ID); err == nil { + view.CreatedAt = entity.UnixMS(rel.CreatedAt) + } + } + return view, nil +} diff --git a/internal/module/friend/logic/profile.go b/internal/module/friend/logic/profile.go new file mode 100644 index 0000000..b0a421a --- /dev/null +++ b/internal/module/friend/logic/profile.go @@ -0,0 +1,104 @@ +package logic + +import ( + "context" + "errors" + + "spark-server/internal/module/friend/entity" + userentity "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" +) + +type UserCard struct { + UID int64 `json:"uid"` + SparkID string `json:"spark_id"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Gender int `json:"gender"` +} + +type FriendView struct { + UserCard + Remark string `json:"remark"` + Relation int `json:"relation"` + CreatedAt int64 `json:"created_at,omitempty"` +} + +type FriendList struct { + Items []*FriendView `json:"items"` +} + +func cardOf(u *userentity.User) UserCard { + if u == nil { + return UserCard{} + } + return UserCard{ + UID: u.ID, SparkID: u.SparkID, Nickname: u.Nickname, + Avatar: u.Avatar, Gender: u.Gender, + } +} + +func (s *Service) List(ctx context.Context, userID int64, limit int) (*FriendList, error) { + if !s.ready() || userID <= 0 { + return nil, errs.ErrUnauthorized + } + rels, err := s.repo.ListFriends(ctx, userID, entity.ClampLimit(limit)) + if err != nil { + return nil, err + } + ids := make([]int64, 0, len(rels)) + for _, rel := range rels { + if other := rel.Other(userID); other > 0 { + ids = append(ids, other) + } + } + notes, err := s.repo.ListRemarks(ctx, userID, ids) + if err != nil { + return nil, err + } + items := make([]*FriendView, 0, len(rels)) + for _, rel := range rels { + other := rel.Other(userID) + u, err := s.users.Get(ctx, other) + if err != nil { + continue + } + items = append(items, &FriendView{ + UserCard: cardOf(u), + Remark: notes[other], + Relation: entity.RelationFriend, + CreatedAt: entity.UnixMS(rel.CreatedAt), + }) + } + return &FriendList{Items: items}, nil +} + +func (s *Service) Get(ctx context.Context, userID, friendID int64) (*FriendView, error) { + if !s.ready() || userID <= 0 { + return nil, errs.ErrUnauthorized + } + if friendID <= 0 { + return nil, errs.ErrInvalidArgument + } + rel, err := s.repo.GetRelation(ctx, userID, friendID) + if err != nil { + if errors.Is(err, errs.ErrNotFound) { + return nil, errs.ErrNotFound + } + return nil, err + } + u, err := s.activeUser(ctx, friendID) + if err != nil { + return nil, err + } + remark, err := s.repo.GetRemark(ctx, userID, friendID) + if err != nil { + return nil, err + } + return &FriendView{ + UserCard: cardOf(u), + Remark: remark, + Relation: entity.RelationFriend, + CreatedAt: entity.UnixMS(rel.CreatedAt), + }, nil +} diff --git a/internal/module/friend/logic/remark.go b/internal/module/friend/logic/remark.go new file mode 100644 index 0000000..f559798 --- /dev/null +++ b/internal/module/friend/logic/remark.go @@ -0,0 +1,43 @@ +package logic + +import ( + "context" + "errors" + "strings" + + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" +) + +type RemarkInput struct { + Remark string `json:"remark" validate:"omitempty,max=64" label:"field.remark" errmsg:"max:validate.friend.remark_invalid"` +} + +func (s *Service) SetRemark(ctx context.Context, userID, friendID int64, in RemarkInput) error { + if !s.ready() || userID <= 0 { + return errs.ErrUnauthorized + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return err + } + if err := s.requireFriend(ctx, userID, friendID); err != nil { + return err + } + return s.repo.SetRemark(ctx, userID, friendID, strings.TrimSpace(in.Remark)) +} + +func (s *Service) requireFriend(ctx context.Context, userID, friendID int64) error { + if friendID <= 0 { + return errs.ErrInvalidArgument + } + _, err := s.repo.GetRelation(ctx, userID, friendID) + if err == nil { + return nil + } + if errors.Is(err, errs.ErrNotFound) { + return errs.ErrNotFound.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendNotFriend, nil)) + } + return err +} diff --git a/internal/module/friend/logic/report.go b/internal/module/friend/logic/report.go new file mode 100644 index 0000000..a6fdea5 --- /dev/null +++ b/internal/module/friend/logic/report.go @@ -0,0 +1,41 @@ +package logic + +import ( + "context" + "strings" + "time" + + "spark-server/internal/module/friend/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" +) + +type ReportInput struct { + Reason int `json:"reason" validate:"required,oneof=1 2 3 4" label:"field.report_reason" errmsg:"required:validate.friend.report_reason_required;oneof:validate.friend.report_reason_invalid"` + Detail string `json:"detail" validate:"omitempty,max=512" label:"field.report_detail" errmsg:"max:validate.friend.report_detail_invalid"` +} + +func (s *Service) Report(ctx context.Context, userID, targetID int64, in ReportInput) error { + if !s.ready() || userID <= 0 { + return errs.ErrUnauthorized + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return err + } + if targetID <= 0 || entity.SameUser(userID, targetID) { + return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendSelf, nil)) + } + if _, err := s.activeUser(ctx, targetID); err != nil { + return err + } + id, err := s.ids.NextID() + if err != nil { + return err + } + return s.repo.CreateReport(ctx, &entity.Report{ + ID: id, UserID: userID, TargetID: targetID, + Reason: in.Reason, Detail: strings.TrimSpace(in.Detail), CreatedAt: time.Now(), + }) +} diff --git a/internal/module/friend/logic/requests.go b/internal/module/friend/logic/requests.go new file mode 100644 index 0000000..4c60c44 --- /dev/null +++ b/internal/module/friend/logic/requests.go @@ -0,0 +1,47 @@ +package logic + +import ( + "context" + + "spark-server/internal/module/friend/entity" + "spark-server/internal/pkg/errs" +) + +type RequestView struct { + ID int64 `json:"id"` + From UserCard `json:"from"` + Reason string `json:"reason"` + Source int `json:"source"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type RequestList struct { + Items []*RequestView `json:"items"` +} + +func (s *Service) ListRequests(ctx context.Context, userID int64, limit int) (*RequestList, error) { + if !s.ready() || userID <= 0 { + return nil, errs.ErrUnauthorized + } + rows, err := s.repo.ListIncoming(ctx, userID, entity.RequestPending, entity.ClampLimit(limit)) + if err != nil { + return nil, err + } + items := make([]*RequestView, 0, len(rows)) + for _, req := range rows { + from, err := s.users.Get(ctx, req.FromUser) + if err != nil { + continue + } + items = append(items, &RequestView{ + ID: req.ID, + From: cardOf(from), + Reason: req.Reason, + Source: req.Source, + CreatedAt: entity.UnixMS(req.CreatedAt), + UpdatedAt: entity.UnixMS(req.UpdatedAt), + }) + } + return &RequestList{Items: items}, nil +} diff --git a/internal/module/friend/logic/service.go b/internal/module/friend/logic/service.go new file mode 100644 index 0000000..a2c962c --- /dev/null +++ b/internal/module/friend/logic/service.go @@ -0,0 +1,74 @@ +package logic + +import ( + "context" + + "spark-server/internal/module/friend/entity" + msgentity "spark-server/internal/module/message/entity" + userentity "spark-server/internal/module/user/entity" +) + +// Repository is friend persistence. Model implements it; do not split this by table. +type Repository interface { + GetRelation(ctx context.Context, a, b int64) (*entity.Relation, error) + ListFriends(ctx context.Context, userID int64, limit int) ([]*entity.Relation, error) + GetRequest(ctx context.Context, id int64) (*entity.Request, error) + GetRequestByPair(ctx context.Context, from, to int64) (*entity.Request, error) + UpsertRequest(ctx context.Context, req *entity.Request) error + ListIncoming(ctx context.Context, toUserID int64, status, limit int) ([]*entity.Request, error) + GetRemark(ctx context.Context, userID, friendID int64) (string, error) + ListRemarks(ctx context.Context, userID int64, friendIDs []int64) (map[int64]string, error) + SetRemark(ctx context.Context, userID, friendID int64, remark string) error + IsBlocked(ctx context.Context, userID, targetID int64) (bool, error) + BlockedEither(ctx context.Context, a, b int64) (bool, error) + CreateBlock(ctx context.Context, b *entity.Block) error + DeleteBlock(ctx context.Context, userID, blockedID int64) error + CreateReport(ctx context.Context, r *entity.Report) error + AcceptFriend(ctx context.Context, in entity.AcceptParams) error + DeleteFriend(ctx context.Context, a, b int64) error +} + +// UserFinder resolves accounts by uid / email / spark_id / qrcode ticket. +type UserFinder interface { + Get(ctx context.Context, id int64) (*userentity.User, error) + GetByEmail(ctx context.Context, email string) (*userentity.User, error) + GetBySparkID(ctx context.Context, sparkID string) (*userentity.User, error) + GetByQRCode(ctx context.Context, qrcode string) (*userentity.User, error) +} + +// IDGen issues Snowflake ids for requests, reports, and system messages. +type IDGen interface { + NextID() (int64, error) +} + +// Greeter writes the "became friends" system message. Nil skips greeting. +type Greeter interface { + BecameFriends(ctx context.Context, userA, userB int64) error +} + +// P2PChats opens (or reuses) the 1:1 conversation for a pair. +type P2PChats interface { + EnsureP2P(ctx context.Context, userA, userB int64) (int64, error) + UpdateLastMsg(ctx context.Context, conversationID, msgID, atMS int64) error +} + +// Messages persists a row in messages. +type Messages interface { + Create(ctx context.Context, m *msgentity.Message) error +} + +// Service is the friend use-case layer (HTTP and TCP share it). +type Service struct { + repo Repository + users UserFinder + ids IDGen + greeter Greeter +} + +func NewService(repo Repository, users UserFinder, ids IDGen, greeter Greeter) *Service { + return &Service{repo: repo, users: users, ids: ids, greeter: greeter} +} + +func (s *Service) ready() bool { + return s != nil && s.repo != nil && s.users != nil && s.ids != nil +} diff --git a/internal/module/friend/model/accept.go b/internal/module/friend/model/accept.go new file mode 100644 index 0000000..782e7c5 --- /dev/null +++ b/internal/module/friend/model/accept.go @@ -0,0 +1,55 @@ +package model + +import ( + "context" + "time" + + "gorm.io/gorm" + + "spark-server/internal/module/friend/entity" + "spark-server/internal/pkg/errs" +) + +func (m *Repo) AcceptFriend(ctx context.Context, in entity.AcceptParams) error { + if in.Relation == nil { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + now := in.Relation.CreatedAt + if now.IsZero() { + now = time.Now() + in.Relation.CreatedAt = now + } + return db.Transaction(func(tx *gorm.DB) error { + row := FriendRelation{UserSmall: in.Relation.UserSmall, UserBig: in.Relation.UserBig, CreatedAt: now} + if err := tx.Where("user_small = ? AND user_big = ?", row.UserSmall, row.UserBig). + Attrs(row).FirstOrCreate(&row).Error; err != nil { + return mapDBErr(err) + } + if err := markRequestsAccepted(tx, in.Requests, now); err != nil { + return err + } + return upsertRemarks(tx, in.Remarks) + }) +} + +func (m *Repo) DeleteFriend(ctx context.Context, a, b int64) error { + db, err := m.session(ctx) + if err != nil { + return err + } + return db.Transaction(func(tx *gorm.DB) error { + small, big := entity.OrderUsers(a, b) + res := tx.Where("user_small = ? AND user_big = ?", small, big).Delete(&FriendRelation{}) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return deleteRemarksBetween(tx, a, b) + }) +} diff --git a/internal/module/friend/model/friend_relation.go b/internal/module/friend/model/friend_relation.go new file mode 100644 index 0000000..91c7823 --- /dev/null +++ b/internal/module/friend/model/friend_relation.go @@ -0,0 +1,56 @@ +package model + +import ( + "context" + "time" + + "spark-server/internal/module/friend/entity" +) + +// FriendRelation maps table friend_relations. +type FriendRelation struct { + UserSmall int64 `gorm:"column:user_small;primaryKey;comment:两人中较小的 user_id,保证同一对用户只有一行"` + UserBig int64 `gorm:"column:user_big;primaryKey;comment:两人中较大的 user_id"` + CreatedAt time.Time `gorm:"column:created_at;comment:成为好友时间"` +} + +func (FriendRelation) TableName() string { return "friend_relations" } + +func (r *FriendRelation) entity() *entity.Relation { + if r == nil { + return nil + } + return &entity.Relation{UserSmall: r.UserSmall, UserBig: r.UserBig, CreatedAt: r.CreatedAt} +} + +func (m *Repo) GetRelation(ctx context.Context, a, b int64) (*entity.Relation, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + small, big := entity.OrderUsers(a, b) + var row FriendRelation + if err := db.Where("user_small = ? AND user_big = ?", small, big).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.entity(), nil +} + +func (m *Repo) ListFriends(ctx context.Context, userID int64, limit int) ([]*entity.Relation, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + limit = entity.ClampLimit(limit) + var rows []FriendRelation + err = db.Where("user_small = ? OR user_big = ?", userID, userID). + Order("created_at DESC").Limit(limit).Find(&rows).Error + if err != nil { + return nil, mapDBErr(err) + } + out := make([]*entity.Relation, 0, len(rows)) + for i := range rows { + out = append(out, rows[i].entity()) + } + return out, nil +} diff --git a/internal/module/friend/model/friend_remark.go b/internal/module/friend/model/friend_remark.go new file mode 100644 index 0000000..93c9b4c --- /dev/null +++ b/internal/module/friend/model/friend_remark.go @@ -0,0 +1,89 @@ +package model + +import ( + "context" + "errors" + "time" + + "gorm.io/gorm" + + "spark-server/internal/module/friend/entity" + "spark-server/internal/pkg/errs" +) + +// FriendRemark maps table friend_remarks. +type FriendRemark struct { + UserID int64 `gorm:"column:user_id;primaryKey;comment:备注所属用户"` + FriendID int64 `gorm:"column:friend_id;primaryKey;comment:被备注的用户"` + Remark string `gorm:"column:remark;comment:备注名,仅对备注者可见"` + UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间"` +} + +func (FriendRemark) TableName() string { return "friend_remarks" } + +func (m *Repo) GetRemark(ctx context.Context, userID, friendID int64) (string, error) { + db, err := m.session(ctx) + if err != nil { + return "", err + } + var row FriendRemark + if err := db.Where("user_id = ? AND friend_id = ?", userID, friendID).Take(&row).Error; err != nil { + mapped := mapDBErr(err) + if errors.Is(mapped, errs.ErrNotFound) { + return "", nil + } + return "", mapped + } + return row.Remark, nil +} + +func (m *Repo) ListRemarks(ctx context.Context, userID int64, friendIDs []int64) (map[int64]string, error) { + out := make(map[int64]string, len(friendIDs)) + if len(friendIDs) == 0 { + return out, nil + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var rows []FriendRemark + if err := db.Where("user_id = ? AND friend_id IN ?", userID, friendIDs).Find(&rows).Error; err != nil { + return nil, mapDBErr(err) + } + for i := range rows { + out[rows[i].FriendID] = rows[i].Remark + } + return out, nil +} + +func (m *Repo) SetRemark(ctx context.Context, userID, friendID int64, remark string) error { + if userID <= 0 || friendID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + row := FriendRemark{UserID: userID, FriendID: friendID, Remark: remark, UpdatedAt: time.Now()} + return mapDBErr(db.Save(&row).Error) +} + +func upsertRemarks(tx *gorm.DB, remarks []entity.Remark) error { + for i := range remarks { + r := remarks[i] + if r.UserID <= 0 || r.FriendID <= 0 || r.Text == "" { + continue + } + row := FriendRemark{UserID: r.UserID, FriendID: r.FriendID, Remark: r.Text, UpdatedAt: r.UpdatedAt} + if err := tx.Save(&row).Error; err != nil { + return mapDBErr(err) + } + } + return nil +} + +func deleteRemarksBetween(tx *gorm.DB, a, b int64) error { + err := tx.Where("(user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)", a, b, b, a). + Delete(&FriendRemark{}).Error + return mapDBErr(err) +} diff --git a/internal/module/friend/model/friend_report.go b/internal/module/friend/model/friend_report.go new file mode 100644 index 0000000..f45b267 --- /dev/null +++ b/internal/module/friend/model/friend_report.go @@ -0,0 +1,36 @@ +package model + +import ( + "context" + "time" + + "spark-server/internal/module/friend/entity" + "spark-server/internal/pkg/errs" +) + +// FriendReport maps table friend_reports. +type FriendReport struct { + ID int64 `gorm:"column:id;primaryKey;comment:Snowflake 投诉 id"` + UserID int64 `gorm:"column:user_id;comment:投诉人 user_id"` + TargetID int64 `gorm:"column:target_id;comment:被投诉人 user_id"` + Reason int `gorm:"column:reason;comment:投诉类型:1=垃圾 2=骚扰 3=不实身份 4=其他"` + Detail string `gorm:"column:detail;comment:投诉说明"` + CreatedAt time.Time `gorm:"column:created_at;comment:投诉时间"` +} + +func (FriendReport) TableName() string { return "friend_reports" } + +func (m *Repo) CreateReport(ctx context.Context, r *entity.Report) error { + if r == nil || r.UserID <= 0 || r.TargetID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + row := FriendReport{ + ID: r.ID, UserID: r.UserID, TargetID: r.TargetID, + Reason: r.Reason, Detail: r.Detail, CreatedAt: r.CreatedAt, + } + return mapDBErr(db.Create(&row).Error) +} diff --git a/internal/module/friend/model/friend_request.go b/internal/module/friend/model/friend_request.go new file mode 100644 index 0000000..7acae3d --- /dev/null +++ b/internal/module/friend/model/friend_request.go @@ -0,0 +1,132 @@ +package model + +import ( + "context" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "spark-server/internal/module/friend/entity" + "spark-server/internal/pkg/errs" +) + +// FriendRequest maps table friend_requests. +type FriendRequest struct { + ID int64 `gorm:"column:id;primaryKey;comment:Snowflake 申请 id"` + FromUser int64 `gorm:"column:from_user;comment:申请人 user_id"` + ToUser int64 `gorm:"column:to_user;comment:被申请人 user_id"` + Source int `gorm:"column:source;comment:来源:1=二维码 2=邮箱 3=spark_id"` + Reason string `gorm:"column:reason;comment:申请理由"` + Remark string `gorm:"column:remark;comment:申请人对对方的备注,同意后写入 friend_remarks"` + Status int `gorm:"column:status;comment:1=待处理 2=已同意 3=已拒绝"` + CreatedAt time.Time `gorm:"column:created_at;comment:首次申请时间"` + UpdatedAt time.Time `gorm:"column:updated_at;comment:最近一次申请或处理时间"` +} + +func (FriendRequest) TableName() string { return "friend_requests" } + +func (r *FriendRequest) entity() *entity.Request { + if r == nil { + return nil + } + return &entity.Request{ + ID: r.ID, FromUser: r.FromUser, ToUser: r.ToUser, Source: r.Source, + Reason: r.Reason, Remark: r.Remark, Status: r.Status, + CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + } +} + +func requestFromEntity(r *entity.Request) FriendRequest { + return FriendRequest{ + ID: r.ID, FromUser: r.FromUser, ToUser: r.ToUser, Source: r.Source, + Reason: r.Reason, Remark: r.Remark, Status: r.Status, + CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + } +} + +func (m *Repo) GetRequest(ctx context.Context, id int64) (*entity.Request, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row FriendRequest + if err := db.Where("id = ?", id).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.entity(), nil +} + +func (m *Repo) GetRequestByPair(ctx context.Context, from, to int64) (*entity.Request, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row FriendRequest + if err := db.Where("from_user = ? AND to_user = ?", from, to).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.entity(), nil +} + +func (m *Repo) UpsertRequest(ctx context.Context, req *entity.Request) error { + if req == nil || req.FromUser <= 0 || req.ToUser <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + row := requestFromEntity(req) + err = db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "from_user"}, {Name: "to_user"}}, + DoUpdates: clause.AssignmentColumns([]string{"source", "reason", "remark", "status", "updated_at"}), + }).Create(&row).Error + if err != nil { + return mapDBErr(err) + } + saved, err := m.GetRequestByPair(ctx, req.FromUser, req.ToUser) + if err != nil { + return err + } + *req = *saved + return nil +} + +func (m *Repo) ListIncoming(ctx context.Context, toUserID int64, status, limit int) ([]*entity.Request, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + limit = entity.ClampLimit(limit) + var rows []FriendRequest + q := db.Where("to_user = ?", toUserID) + if status > 0 { + q = q.Where("status = ?", status) + } + if err := q.Order("updated_at DESC").Limit(limit).Find(&rows).Error; err != nil { + return nil, mapDBErr(err) + } + out := make([]*entity.Request, 0, len(rows)) + for i := range rows { + out = append(out, rows[i].entity()) + } + return out, nil +} + +func markRequestsAccepted(tx *gorm.DB, ids []int64, at time.Time) error { + if len(ids) == 0 { + return nil + } + return mapDBErr(tx.Model(&FriendRequest{}).Where("id IN ?", ids).Updates(map[string]any{ + "status": entity.RequestAccepted, + "updated_at": at, + }).Error) +} + +func rejectPairRequests(tx *gorm.DB, a, b int64, at time.Time) error { + return mapDBErr(tx.Model(&FriendRequest{}). + Where("(from_user = ? AND to_user = ?) OR (from_user = ? AND to_user = ?)", a, b, b, a). + Where("status = ?", entity.RequestPending). + Updates(map[string]any{"status": entity.RequestRejected, "updated_at": at}).Error) +} diff --git a/internal/module/friend/model/memory.go b/internal/module/friend/model/memory.go new file mode 100644 index 0000000..926992b --- /dev/null +++ b/internal/module/friend/model/memory.go @@ -0,0 +1,153 @@ +package model + +import ( + "context" + "sort" + "sync" + "time" + + "spark-server/internal/module/friend/entity" + "spark-server/internal/pkg/errs" +) + +type pairKey struct { + Small int64 + Big int64 +} + +type requestKey struct { + From int64 + To int64 +} + +type remarkKey struct { + UserID int64 + FriendID int64 +} + +type blockKey struct { + UserID int64 + BlockedID int64 +} + +// MemoryRepo is an in-process friend store for tests and empty-DSN bring-up. +type MemoryRepo struct { + mu sync.Mutex + relations map[pairKey]*entity.Relation + requests map[int64]*entity.Request + byPair map[requestKey]int64 + remarks map[remarkKey]string + blocks map[blockKey]time.Time + reports []*entity.Report +} + +func NewMemoryRepo() *MemoryRepo { + return &MemoryRepo{ + relations: make(map[pairKey]*entity.Relation), + requests: make(map[int64]*entity.Request), + byPair: make(map[requestKey]int64), + remarks: make(map[remarkKey]string), + blocks: make(map[blockKey]time.Time), + } +} + +func (m *MemoryRepo) GetRelation(_ context.Context, a, b int64) (*entity.Relation, error) { + m.mu.Lock() + defer m.mu.Unlock() + rel, ok := m.relations[pairOf(a, b)] + if !ok { + return nil, errs.ErrNotFound + } + cp := *rel + return &cp, nil +} + +func (m *MemoryRepo) ListFriends(_ context.Context, userID int64, limit int) ([]*entity.Relation, error) { + m.mu.Lock() + defer m.mu.Unlock() + limit = entity.ClampLimit(limit) + out := make([]*entity.Relation, 0) + for _, rel := range m.relations { + if rel.UserSmall == userID || rel.UserBig == userID { + cp := *rel + out = append(out, &cp) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (m *MemoryRepo) GetRequest(_ context.Context, id int64) (*entity.Request, error) { + m.mu.Lock() + defer m.mu.Unlock() + req, ok := m.requests[id] + if !ok { + return nil, errs.ErrNotFound + } + cp := *req + return &cp, nil +} + +func (m *MemoryRepo) GetRequestByPair(_ context.Context, from, to int64) (*entity.Request, error) { + m.mu.Lock() + defer m.mu.Unlock() + id, ok := m.byPair[requestKey{From: from, To: to}] + if !ok { + return nil, errs.ErrNotFound + } + cp := *m.requests[id] + return &cp, nil +} + +func (m *MemoryRepo) UpsertRequest(_ context.Context, req *entity.Request) error { + if req == nil || req.FromUser <= 0 || req.ToUser <= 0 { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + key := requestKey{From: req.FromUser, To: req.ToUser} + if id, ok := m.byPair[key]; ok { + cur := m.requests[id] + cur.Source = req.Source + cur.Reason = req.Reason + cur.Remark = req.Remark + cur.Status = req.Status + cur.UpdatedAt = req.UpdatedAt + *req = *cur + return nil + } + cp := *req + m.requests[req.ID] = &cp + m.byPair[key] = req.ID + return nil +} + +func (m *MemoryRepo) ListIncoming(_ context.Context, toUserID int64, status, limit int) ([]*entity.Request, error) { + m.mu.Lock() + defer m.mu.Unlock() + limit = entity.ClampLimit(limit) + out := make([]*entity.Request, 0) + for _, req := range m.requests { + if req.ToUser != toUserID { + continue + } + if status > 0 && req.Status != status { + continue + } + cp := *req + out = append(out, &cp) + } + sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt.After(out[j].UpdatedAt) }) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func pairOf(a, b int64) pairKey { + small, big := entity.OrderUsers(a, b) + return pairKey{Small: small, Big: big} +} diff --git a/internal/module/friend/model/memory_write.go b/internal/module/friend/model/memory_write.go new file mode 100644 index 0000000..ce5b91c --- /dev/null +++ b/internal/module/friend/model/memory_write.go @@ -0,0 +1,151 @@ +package model + +import ( + "context" + "time" + + "spark-server/internal/module/friend/entity" + "spark-server/internal/pkg/errs" +) + +func (m *MemoryRepo) GetRemark(_ context.Context, userID, friendID int64) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.remarks[remarkKey{UserID: userID, FriendID: friendID}], nil +} + +func (m *MemoryRepo) ListRemarks(_ context.Context, userID int64, friendIDs []int64) (map[int64]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make(map[int64]string, len(friendIDs)) + for _, id := range friendIDs { + if s := m.remarks[remarkKey{UserID: userID, FriendID: id}]; s != "" { + out[id] = s + } + } + return out, nil +} + +func (m *MemoryRepo) SetRemark(_ context.Context, userID, friendID int64, remark string) error { + if userID <= 0 || friendID <= 0 { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + key := remarkKey{UserID: userID, FriendID: friendID} + if remark == "" { + delete(m.remarks, key) + return nil + } + m.remarks[key] = remark + return nil +} + +func (m *MemoryRepo) IsBlocked(_ context.Context, userID, targetID int64) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + _, ok := m.blocks[blockKey{UserID: userID, BlockedID: targetID}] + return ok, nil +} + +func (m *MemoryRepo) BlockedEither(_ context.Context, a, b int64) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + _, ab := m.blocks[blockKey{UserID: a, BlockedID: b}] + _, ba := m.blocks[blockKey{UserID: b, BlockedID: a}] + return ab || ba, nil +} + +func (m *MemoryRepo) CreateBlock(_ context.Context, b *entity.Block) error { + if b == nil || b.UserID <= 0 || b.BlockedID <= 0 { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + now := b.CreatedAt + if now.IsZero() { + now = time.Now() + } + m.blocks[blockKey{UserID: b.UserID, BlockedID: b.BlockedID}] = now + delete(m.relations, pairOf(b.UserID, b.BlockedID)) + delete(m.remarks, remarkKey{UserID: b.UserID, FriendID: b.BlockedID}) + delete(m.remarks, remarkKey{UserID: b.BlockedID, FriendID: b.UserID}) + m.rejectPendingLocked(b.UserID, b.BlockedID, now) + return nil +} + +func (m *MemoryRepo) DeleteBlock(_ context.Context, userID, blockedID int64) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.blocks, blockKey{UserID: userID, BlockedID: blockedID}) + return nil +} + +func (m *MemoryRepo) CreateReport(_ context.Context, r *entity.Report) error { + if r == nil || r.UserID <= 0 || r.TargetID <= 0 { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + cp := *r + m.reports = append(m.reports, &cp) + return nil +} + +func (m *MemoryRepo) AcceptFriend(_ context.Context, in entity.AcceptParams) error { + if in.Relation == nil { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + now := in.Relation.CreatedAt + if now.IsZero() { + now = time.Now() + in.Relation.CreatedAt = now + } + key := pairKey{Small: in.Relation.UserSmall, Big: in.Relation.UserBig} + if _, ok := m.relations[key]; !ok { + cp := *in.Relation + m.relations[key] = &cp + } + for _, id := range in.Requests { + if req := m.requests[id]; req != nil { + req.Status = entity.RequestAccepted + req.UpdatedAt = now + } + } + for i := range in.Remarks { + r := in.Remarks[i] + if r.Text == "" { + continue + } + m.remarks[remarkKey{UserID: r.UserID, FriendID: r.FriendID}] = r.Text + } + return nil +} + +func (m *MemoryRepo) DeleteFriend(_ context.Context, a, b int64) error { + m.mu.Lock() + defer m.mu.Unlock() + key := pairOf(a, b) + if _, ok := m.relations[key]; !ok { + return errs.ErrNotFound + } + delete(m.relations, key) + delete(m.remarks, remarkKey{UserID: a, FriendID: b}) + delete(m.remarks, remarkKey{UserID: b, FriendID: a}) + return nil +} + +func (m *MemoryRepo) rejectPendingLocked(a, b int64, at time.Time) { + for _, req := range m.requests { + if req.Status != entity.RequestPending { + continue + } + pair := (req.FromUser == a && req.ToUser == b) || (req.FromUser == b && req.ToUser == a) + if pair { + req.Status = entity.RequestRejected + req.UpdatedAt = at + } + } +} diff --git a/internal/module/friend/model/repo.go b/internal/module/friend/model/repo.go new file mode 100644 index 0000000..38f44a7 --- /dev/null +++ b/internal/module/friend/model/repo.go @@ -0,0 +1,41 @@ +package model + +import ( + "context" + "errors" + + mysqldriver "github.com/go-sql-driver/mysql" + "gorm.io/gorm" + + "spark-server/internal/pkg/errs" +) + +// Repo is MySQL persistence for friend tables. +type Repo struct { + gdb *gorm.DB +} + +func NewRepo(gdb *gorm.DB) *Repo { + return &Repo{gdb: gdb} +} + +func (m *Repo) session(ctx context.Context) (*gorm.DB, error) { + if m == nil || m.gdb == nil { + return nil, errs.ErrAvailable + } + return m.gdb.WithContext(ctx), nil +} + +func mapDBErr(err error) error { + if err == nil { + return nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return errs.ErrNotFound + } + var me *mysqldriver.MySQLError + if errors.As(err, &me) && me.Number == 1062 { + return errs.ErrDuplicate + } + return errs.ErrInner.WithError("friend-mysql", err) +} diff --git a/internal/module/friend/model/user_block.go b/internal/module/friend/model/user_block.go new file mode 100644 index 0000000..9ec6898 --- /dev/null +++ b/internal/module/friend/model/user_block.go @@ -0,0 +1,77 @@ +package model + +import ( + "context" + "time" + + "gorm.io/gorm" + + "spark-server/internal/module/friend/entity" + "spark-server/internal/pkg/errs" +) + +// UserBlock maps table user_blocks. +type UserBlock struct { + UserID int64 `gorm:"column:user_id;primaryKey;comment:拉黑人 user_id"` + BlockedID int64 `gorm:"column:blocked_id;primaryKey;comment:被拉黑人 user_id"` + CreatedAt time.Time `gorm:"column:created_at;comment:拉黑时间"` +} + +func (UserBlock) TableName() string { return "user_blocks" } + +func (m *Repo) IsBlocked(ctx context.Context, userID, targetID int64) (bool, error) { + db, err := m.session(ctx) + if err != nil { + return false, err + } + var n int64 + if err := db.Model(&UserBlock{}).Where("user_id = ? AND blocked_id = ?", userID, targetID).Count(&n).Error; err != nil { + return false, mapDBErr(err) + } + return n > 0, nil +} + +func (m *Repo) BlockedEither(ctx context.Context, a, b int64) (bool, error) { + me, err := m.IsBlocked(ctx, a, b) + if err != nil || me { + return me, err + } + return m.IsBlocked(ctx, b, a) +} + +func (m *Repo) CreateBlock(ctx context.Context, b *entity.Block) error { + if b == nil || b.UserID <= 0 || b.BlockedID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + now := b.CreatedAt + if now.IsZero() { + now = time.Now() + } + return db.Transaction(func(tx *gorm.DB) error { + row := UserBlock{UserID: b.UserID, BlockedID: b.BlockedID, CreatedAt: now} + if err := tx.Where("user_id = ? AND blocked_id = ?", b.UserID, b.BlockedID). + Attrs(row).FirstOrCreate(&row).Error; err != nil { + return mapDBErr(err) + } + small, big := entity.OrderUsers(b.UserID, b.BlockedID) + if err := tx.Where("user_small = ? AND user_big = ?", small, big).Delete(&FriendRelation{}).Error; err != nil { + return mapDBErr(err) + } + if err := deleteRemarksBetween(tx, b.UserID, b.BlockedID); err != nil { + return err + } + return rejectPairRequests(tx, b.UserID, b.BlockedID, now) + }) +} + +func (m *Repo) DeleteBlock(ctx context.Context, userID, blockedID int64) error { + db, err := m.session(ctx) + if err != nil { + return err + } + return mapDBErr(db.Where("user_id = ? AND blocked_id = ?", userID, blockedID).Delete(&UserBlock{}).Error) +} diff --git a/internal/module/friend/service.go b/internal/module/friend/service.go deleted file mode 100644 index 60b5b4d..0000000 --- a/internal/module/friend/service.go +++ /dev/null @@ -1,7 +0,0 @@ -package friend - -import "context" - -type Service interface { - CanSend(ctx context.Context, fromUserID, toUserID int64) error -} diff --git a/internal/module/group/entity/collage.go b/internal/module/group/entity/collage.go new file mode 100644 index 0000000..dfe25ad --- /dev/null +++ b/internal/module/group/entity/collage.go @@ -0,0 +1,105 @@ +package entity + +import ( + "image" + "image/color" + "image/draw" +) + +const ( + collageSize = 480 + collageGap = 8 +) + +var ( + collageBG = color.RGBA{R: 237, G: 237, B: 237, A: 255} + collageFill = color.RGBA{R: 216, G: 216, B: 216, A: 255} +) + +// PlaceholderFace is a solid tile used when a member avatar cannot be loaded. +func PlaceholderFace() *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, 256, 256)) + draw.Draw(img, img.Bounds(), &image.Uniform{C: collageFill}, image.Point{}, draw.Src) + return img +} + +// Collage tiles as many member avatars as given (1–9). Two people are two tiles, not a 3×3 grid. +func Collage(faces []image.Image) *image.RGBA { + if len(faces) > MaxCollageFaces { + faces = faces[:MaxCollageFaces] + } + dst := image.NewRGBA(image.Rect(0, 0, collageSize, collageSize)) + draw.Draw(dst, dst.Bounds(), &image.Uniform{C: collageBG}, image.Point{}, draw.Src) + cells := collageCells(len(faces)) + for i := range cells { + r := cells[i] + src := image.Image(&image.Uniform{C: collageFill}) + if i < len(faces) && faces[i] != nil { + src = faces[i] + } + blitSquare(dst, src, r) + } + return dst +} + +func collageCells(n int) []image.Rectangle { + if n < 1 { + return nil + } + if n > MaxCollageFaces { + n = MaxCollageFaces + } + cols := 1 + if n >= 5 { + cols = 3 + } else if n >= 2 { + cols = 2 + } + rows := (n + cols - 1) / cols + cell := (collageSize - collageGap*(cols+1)) / cols + gridW := cols*cell + collageGap*(cols-1) + gridH := rows*cell + collageGap*(rows-1) + ox := (collageSize - gridW) / 2 + oy := (collageSize - gridH) / 2 + first := n % cols + if first == 0 { + first = cols + } + out := make([]image.Rectangle, 0, n) + i := 0 + for row := 0; row < rows && i < n; row++ { + count := cols + if row == 0 { + count = first + } + rowW := count*cell + collageGap*(count-1) + x0 := ox + (gridW-rowW)/2 + y := oy + row*(cell+collageGap) + for c := 0; c < count && i < n; c++ { + x := x0 + c*(cell+collageGap) + out = append(out, image.Rect(x, y, x+cell, y+cell)) + i++ + } + } + return out +} + +func blitSquare(dst *image.RGBA, src image.Image, r image.Rectangle) { + sb := src.Bounds() + side := sb.Dx() + if h := sb.Dy(); h < side { + side = h + } + if side < 1 || r.Empty() { + return + } + sx0 := sb.Min.X + (sb.Dx()-side)/2 + sy0 := sb.Min.Y + (sb.Dy()-side)/2 + w, h := r.Dx(), r.Dy() + for y := 0; y < h; y++ { + sy := sy0 + y*side/h + for x := 0; x < w; x++ { + dst.Set(r.Min.X+x, r.Min.Y+y, src.At(sx0+x*side/w, sy)) + } + } +} diff --git a/internal/module/group/entity/collage_test.go b/internal/module/group/entity/collage_test.go new file mode 100644 index 0000000..b1e4959 --- /dev/null +++ b/internal/module/group/entity/collage_test.go @@ -0,0 +1,67 @@ +package entity + +import ( + "image" + "image/color" + "image/draw" + "testing" +) + +func TestCollageLayout(t *testing.T) { + if got := collageCells(0); len(got) != 0 { + t.Fatalf("empty %d", len(got)) + } + two := collageCells(2) + if len(two) != 2 { + t.Fatalf("two %d", len(two)) + } + if two[0].Min.Y != two[1].Min.Y || two[0].Max.X >= two[1].Min.X { + t.Fatalf("two should sit on one row: %+v", two) + } + nine := collageCells(9) + if len(nine) != 9 { + t.Fatalf("nine %d", len(nine)) + } + if two[0].Dx() <= nine[0].Dx() { + t.Fatalf("two-person tiles should be larger than 3x3: two=%d nine=%d", two[0].Dx(), nine[0].Dx()) + } + three := collageCells(3) + if len(three) != 3 { + t.Fatalf("three %d", len(three)) + } + if three[0].Min.X <= three[1].Min.X { + t.Fatalf("first row should be centered: %+v", three) + } +} + +func TestCollageColors(t *testing.T) { + red := solid(color.RGBA{R: 255, A: 255}) + green := solid(color.RGBA{G: 255, A: 255}) + blue := solid(color.RGBA{B: 255, A: 255}) + img := Collage([]image.Image{red, green, blue}) + if img.Bounds().Dx() != collageSize { + t.Fatalf("size %d", img.Bounds().Dx()) + } + cells := collageCells(3) + assertNear(t, img.At(mid(cells[0]).X, mid(cells[0]).Y), color.RGBA{R: 255, A: 255}) + assertNear(t, img.At(mid(cells[1]).X, mid(cells[1]).Y), color.RGBA{G: 255, A: 255}) + assertNear(t, img.At(mid(cells[2]).X, mid(cells[2]).Y), color.RGBA{B: 255, A: 255}) +} + +func solid(c color.RGBA) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, 16, 16)) + draw.Draw(img, img.Bounds(), &image.Uniform{C: c}, image.Point{}, draw.Src) + return img +} + +func mid(r image.Rectangle) image.Point { + return image.Pt((r.Min.X+r.Max.X)/2, (r.Min.Y+r.Max.Y)/2) +} + +func assertNear(t *testing.T, got color.Color, want color.RGBA) { + t.Helper() + r, g, b, a := got.RGBA() + if r>>8 != uint32(want.R) || g>>8 != uint32(want.G) || b>>8 != uint32(want.B) || a>>8 != uint32(want.A) { + t.Fatalf("got rgba %d %d %d %d want %+v", r>>8, g>>8, b>>8, a>>8, want) + } +} diff --git a/internal/module/group/entity/group.go b/internal/module/group/entity/group.go new file mode 100644 index 0000000..2cb5a10 --- /dev/null +++ b/internal/module/group/entity/group.go @@ -0,0 +1,52 @@ +// Package entity holds group domain types. Persistence belongs in model. +package entity + +const ( + StatusActive = 1 + StatusDissolved = 2 + + MaxNameLen = 32 + MaxMembers = 200 + ListDefault = 20 + ListMax = 100 + MemberListMax = 200 + MaxCollageFaces = 9 +) + +// Group is a type-2 chat. ID is the AUTO_INCREMENT 群号; ConversationID is Snowflake. +type Group struct { + ID int64 + ConversationID int64 + Name string + Avatar string + OwnerID int64 + MutedAll bool + MemberCount int + Status int + CreatedAtMS int64 + UpdatedAtMS int64 +} + +func (g *Group) Active() bool { + return g != nil && g.Status == StatusActive +} + +func ClampLimit(limit int) int { + if limit <= 0 { + return ListDefault + } + if limit > ListMax { + return ListMax + } + return limit +} + +func ClampMemberLimit(limit int) int { + if limit <= 0 { + return MemberListMax + } + if limit > MemberListMax { + return MemberListMax + } + return limit +} diff --git a/internal/module/group/entity/member.go b/internal/module/group/entity/member.go new file mode 100644 index 0000000..429fe6c --- /dev/null +++ b/internal/module/group/entity/member.go @@ -0,0 +1,51 @@ +package entity + +const ( + RoleOwner = 1 + RoleAdmin = 2 + RoleMember = 3 +) + +// Member is a row in group_members. SpeakMuted is send-ban, not 免打扰. +type Member struct { + GroupID int64 + UserID int64 + Role int + Nickname string + SpeakMuted bool + JoinedAtMS int64 +} + +func ValidRole(role int) bool { + return role == RoleOwner || role == RoleAdmin || role == RoleMember +} + +func (m *Member) CanManage() bool { + return m != nil && (m.Role == RoleOwner || m.Role == RoleAdmin) +} + +func (m *Member) CanSpeak(mutedAll bool) bool { + if m == nil || m.SpeakMuted { + return false + } + if mutedAll && m.Role == RoleMember { + return false + } + return true +} + +func UniqueUIDs(ids []int64) []int64 { + seen := make(map[int64]struct{}, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} diff --git a/internal/module/group/entity/system.go b/internal/module/group/entity/system.go new file mode 100644 index 0000000..791cdc2 --- /dev/null +++ b/internal/module/group/entity/system.go @@ -0,0 +1,10 @@ +package entity + +const ( + SystemKindCreated = "group.created" + SystemKindInvite = "group.invite" + SystemKindLeave = "group.leave" + SystemKindKick = "group.kick" + SystemKindDissolve = "group.dissolve" + SystemKindTransfer = "group.transfer" +) diff --git a/internal/module/group/logic/avatar.go b/internal/module/group/logic/avatar.go new file mode 100644 index 0000000..105826e --- /dev/null +++ b/internal/module/group/logic/avatar.go @@ -0,0 +1,137 @@ +package logic + +import ( + "bytes" + "context" + "image" + _ "image/gif" // member avatars + "image/jpeg" + _ "image/png" // member avatars + "strconv" + "strings" + + "spark-server/internal/module/group/entity" + uploadentity "spark-server/internal/module/upload/entity" + userentity "spark-server/internal/module/user/entity" +) + +const collageJPEG = "image/jpeg" + +func (s *Service) CanSetAvatar(ctx context.Context, userID, groupID int64) error { + if _, err := s.loadActive(ctx, groupID); err != nil { + return err + } + _, err := s.requireManage(ctx, groupID, userID) + return err +} + +func validGroupAvatar(groupID int64, key string) bool { + key = strings.TrimSpace(key) + if key == "" { + return true + } + if groupID <= 0 || !uploadentity.ValidObjectKey(key) { + return false + } + return strings.HasPrefix(key, uploadentity.PrefixGroup+strconv.FormatInt(groupID, 10)+"/") +} + +func (s *Service) stampAvatar(ctx context.Context, g *entity.Group, ownerID int64, invitees []int64) { + if s.objects == nil || g == nil || g.ID <= 0 { + return + } + raw, err := encodeCollage(s.memberFaces(ctx, ownerID, invitees)) + if err == nil && s.putAvatar(ctx, g, raw, collageJPEG, "jpg") { + return + } + s.stampFallbackAvatar(ctx, g) +} + +func (s *Service) stampFallbackAvatar(ctx context.Context, g *entity.Group) { + raw, err := userentity.LetterAvatarPNG(g.Name) + if err == nil && s.putAvatar(ctx, g, raw, "image/png", "png") { + return + } + raw, err = encodeCollage([]image.Image{entity.PlaceholderFace()}) + if err != nil { + return + } + s.putAvatar(ctx, g, raw, collageJPEG, "jpg") +} + +func (s *Service) putAvatar(ctx context.Context, g *entity.Group, raw []byte, mime, ext string) bool { + if g == nil || len(raw) == 0 { + return false + } + key := uploadentity.PrefixGroup + strconv.FormatInt(g.ID, 10) + "/default." + ext + if objectID, err := s.ids.NextID(); err == nil { + key = uploadentity.BuildObjectKey(uploadentity.PurposeGroupAvatar, g.ID, objectID, "", ext) + } + if err := s.objects.Put(ctx, key, raw, mime); err != nil { + return false + } + g.Avatar = key + if err := s.repo.UpdateProfile(ctx, g); err != nil { + g.Avatar = "" + return false + } + return true +} + +func (s *Service) memberFaces(ctx context.Context, ownerID int64, invitees []int64) []image.Image { + uids := append([]int64{ownerID}, invitees...) + if len(uids) > entity.MaxCollageFaces { + uids = uids[:entity.MaxCollageFaces] + } + out := make([]image.Image, 0, len(uids)) + for _, uid := range uids { + out = append(out, s.faceOf(ctx, uid)) + } + return out +} + +func (s *Service) faceOf(ctx context.Context, uid int64) image.Image { + u, err := s.users.Get(ctx, uid) + if err == nil && u != nil { + if img, ok := decodeAvatar(s.avatarBytes(ctx, u.Avatar)); ok { + return img + } + if raw, err := userentity.LetterAvatarPNG(u.Nickname); err == nil { + if img, ok := decodeAvatar(raw); ok { + return img + } + } + } + return entity.PlaceholderFace() +} + +func (s *Service) avatarBytes(ctx context.Context, key string) []byte { + key = strings.TrimSpace(key) + if !uploadentity.ValidObjectKey(key) { + return nil + } + raw, err := s.objects.Get(ctx, key) + if err != nil { + return nil + } + return raw +} + +func decodeAvatar(raw []byte) (image.Image, bool) { + if len(raw) == 0 { + return nil, false + } + img, _, err := image.Decode(bytes.NewReader(raw)) + if err != nil { + return nil, false + } + return img, true +} + +func encodeCollage(faces []image.Image) ([]byte, error) { + var buf bytes.Buffer + if err := jpeg.Encode(&buf, entity.Collage(faces), &jpeg.Options{Quality: 85}); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/module/group/logic/create.go b/internal/module/group/logic/create.go new file mode 100644 index 0000000..9090eca --- /dev/null +++ b/internal/module/group/logic/create.go @@ -0,0 +1,83 @@ +package logic + +import ( + "context" + "strings" + + "spark-server/internal/module/group/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" +) + +type CreateInput struct { + Name string `json:"name" validate:"required,min=1,max=32" label:"field.group_name" errmsg:"required:validate.group.name_required;min:validate.group.name_invalid;max:validate.group.name_invalid"` + MemberUIDs []int64 `json:"member_uids" validate:"required,min=1,max=199,dive,gt=0" label:"field.member_uids" errmsg:"required:validate.group.members_required;min:validate.group.members_required;max:validate.group.members_invalid"` +} + +func (s *Service) Create(ctx context.Context, ownerID int64, in *CreateInput) (*View, error) { + if !s.ready() || ownerID <= 0 { + return nil, errs.ErrUnauthorized + } + if in == nil { + return nil, errs.ErrInvalidArgument + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return nil, err + } + invitees, err := s.collectInvitees(ctx, ownerID, in.MemberUIDs) + if err != nil { + return nil, err + } + convID, err := s.ids.NextID() + if err != nil { + return nil, err + } + now := nowMS() + g := &entity.Group{ + ConversationID: convID, Name: strings.TrimSpace(in.Name), + OwnerID: ownerID, MemberCount: 1 + len(invitees), Status: entity.StatusActive, + CreatedAtMS: now, UpdatedAtMS: now, + } + members := ownerAndInvitees(g.ID, ownerID, invitees, now) + if err := s.repo.CreateGroup(ctx, g, members); err != nil { + return nil, err + } + s.invalidateMembers(g.ConversationID) + s.stampAvatar(ctx, g, ownerID, invitees) + _ = s.notice(ctx, g, ownerID, entity.SystemKindCreated, invitees) + return viewOf(g, entity.RoleOwner), nil +} + +func (s *Service) collectInvitees(ctx context.Context, ownerID int64, uids []int64) ([]int64, error) { + lang := reqctx.Lang(ctx) + out := make([]int64, 0, len(uids)) + for _, uid := range entity.UniqueUIDs(uids) { + if uid == ownerID { + continue + } + if err := s.activeUser(ctx, uid); err != nil { + return nil, err + } + if err := s.requireFriend(ctx, ownerID, uid); err != nil { + return nil, err + } + out = append(out, uid) + } + if len(out) == 0 { + return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyGroupMembersRequired, nil)) + } + if 1+len(out) > entity.MaxMembers { + return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyGroupFull, nil)) + } + return out, nil +} + +func ownerAndInvitees(groupID, ownerID int64, invitees []int64, now int64) []*entity.Member { + members := []*entity.Member{{GroupID: groupID, UserID: ownerID, Role: entity.RoleOwner, JoinedAtMS: now}} + for _, uid := range invitees { + members = append(members, &entity.Member{GroupID: groupID, UserID: uid, Role: entity.RoleMember, JoinedAtMS: now}) + } + return members +} diff --git a/internal/module/group/logic/get.go b/internal/module/group/logic/get.go new file mode 100644 index 0000000..a8fc602 --- /dev/null +++ b/internal/module/group/logic/get.go @@ -0,0 +1,41 @@ +package logic + +import ( + "context" + + "spark-server/internal/pkg/errs" +) + +func (s *Service) Get(ctx context.Context, userID, groupID int64) (*View, error) { + if !s.ready() || userID <= 0 { + return nil, errs.ErrUnauthorized + } + g, err := s.loadActive(ctx, groupID) + if err != nil { + return nil, err + } + mem, err := s.requireMember(ctx, groupID, userID) + if err != nil { + return nil, err + } + return viewOf(g, mem.Role), nil +} + +func (s *Service) List(ctx context.Context, userID int64, limit int) (*List, error) { + if !s.ready() || userID <= 0 { + return nil, errs.ErrUnauthorized + } + groups, err := s.repo.ListByUser(ctx, userID, limit) + if err != nil { + return nil, err + } + items := make([]*View, 0, len(groups)) + for _, g := range groups { + role := 0 + if mem, err := s.repo.GetMember(ctx, g.ID, userID); err == nil { + role = mem.Role + } + items = append(items, viewOf(g, role)) + } + return &List{Items: items}, nil +} diff --git a/internal/module/group/logic/group_test.go b/internal/module/group/logic/group_test.go new file mode 100644 index 0000000..c4408d5 --- /dev/null +++ b/internal/module/group/logic/group_test.go @@ -0,0 +1,269 @@ +package logic + +import ( + "bytes" + "context" + "errors" + "image/jpeg" + "strconv" + "strings" + "testing" + + "spark-server/internal/infrastructure/id" + convlogic "spark-server/internal/module/conversation/logic" + convmodel "spark-server/internal/module/conversation/model" + delivermodel "spark-server/internal/module/deliver/model" + friendlogic "spark-server/internal/module/friend/logic" + friendmodel "spark-server/internal/module/friend/model" + "spark-server/internal/module/group/entity" + groupmodel "spark-server/internal/module/group/model" + msgmodel "spark-server/internal/module/message/model" + userentity "spark-server/internal/module/user/entity" + userlogic "spark-server/internal/module/user/logic" + usermodel "spark-server/internal/module/user/model" + "spark-server/internal/pkg/errs" +) + +func testGroups(t *testing.T) (*Service, *friendlogic.Service, *userlogic.Service) { + t.Helper() + return testGroupsStore(t, nil) +} + +type testObjects interface { + Get(ctx context.Context, key string) ([]byte, error) + Put(ctx context.Context, key string, body []byte, contentType string) error +} + +func testGroupsStore(t *testing.T, objects testObjects) (*Service, *friendlogic.Service, *userlogic.Service) { + t.Helper() + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + var userObjs userlogic.Objects + var groupObjs Objects + if objects != nil { + userObjs = objects + groupObjs = objects + } + users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, userObjs) + friends := friendlogic.NewService( + friendmodel.NewMemoryRepo(), users, ids, + friendlogic.NewGreeter(convlogic.NewP2PService(convmodel.NewMemoryP2P(), ids), msgmodel.NewMemoryRepo(), ids, delivermodel.NewMemoryQueue()), + ) + groups := NewService(groupmodel.NewMemoryRepo(), users, friends, ids, msgmodel.NewMemoryRepo(), delivermodel.NewMemoryQueue(), groupObjs) + return groups, friends, users +} + +func login(t *testing.T, users *userlogic.Service, email string, device uint64) *userentity.User { + t.Helper() + u, err := users.AuthenticateEmail(t.Context(), email, "1", &userentity.LoginDevice{DeviceID: device, DeviceType: userentity.DeviceIOS}) + if err != nil { + t.Fatal(err) + } + return u +} + +func becomeFriends(t *testing.T, friends *friendlogic.Service, a, b *userentity.User) { + t.Helper() + ctx := t.Context() + req, err := friends.Apply(ctx, a.ID, &friendlogic.ApplyInput{UID: b.ID}) + if err != nil { + t.Fatal(err) + } + if _, err := friends.Accept(ctx, b.ID, friendlogic.AcceptInput{RequestID: req.RequestID}); err != nil { + t.Fatal(err) + } +} + +func TestCreateGroupAutoIncrementID(t *testing.T) { + svc, friends, users := testGroups(t) + ctx := t.Context() + a := login(t, users, "test01@spark.com", 1) + b := login(t, users, "test02@spark.com", 2) + c := login(t, users, "test03@spark.com", 3) + becomeFriends(t, friends, a, b) + becomeFriends(t, friends, a, c) + + g, err := svc.Create(ctx, a.ID, &CreateInput{Name: "周末局", MemberUIDs: []int64{b.ID, c.ID}}) + if err != nil { + t.Fatal(err) + } + if g.ID != 1 || g.ConversationID == g.ID || g.ConversationID <= 0 || g.Avatar != "" { + t.Fatalf("id=%d conv=%d avatar=%q", g.ID, g.ConversationID, g.Avatar) + } + if g.MemberCount != 3 || g.Role != entity.RoleOwner || g.OwnerID != a.ID { + t.Fatalf("%+v", g) + } + second, err := svc.Create(ctx, a.ID, &CreateInput{Name: "第二群", MemberUIDs: []int64{b.ID}}) + if err != nil || second.ID != 2 { + t.Fatalf("second id=%d err=%v", second.ID, err) + } + if err := svc.CanSend(ctx, b.ID, g.ConversationID); err != nil { + t.Fatal(err) + } + ids, err := svc.MemberIDs(ctx, g.ConversationID) + if err != nil || len(ids) != 3 { + t.Fatalf("members %v %v", ids, err) + } +} + +func TestGroupInviteKickLeaveMute(t *testing.T) { + svc, friends, users := testGroups(t) + ctx := t.Context() + a := login(t, users, "test01@spark.com", 1) + b := login(t, users, "test02@spark.com", 2) + c := login(t, users, "test03@spark.com", 3) + becomeFriends(t, friends, a, b) + becomeFriends(t, friends, a, c) + becomeFriends(t, friends, b, c) + + g, err := svc.Create(ctx, a.ID, &CreateInput{Name: "局", MemberUIDs: []int64{b.ID}}) + if err != nil { + t.Fatal(err) + } + if _, err := svc.Create(ctx, a.ID, &CreateInput{Name: "x", MemberUIDs: []int64{999}}); err == nil { + t.Fatal("missing user") + } + if _, err := svc.Invite(ctx, a.ID, g.ID, &InviteInput{UIDs: []int64{c.ID}}); err != nil { + t.Fatal(err) + } + if err := svc.RemoveMember(ctx, a.ID, g.ID, a.ID); !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("owner leave %v", err) + } + if err := svc.RemoveMember(ctx, a.ID, g.ID, c.ID); err != nil { + t.Fatal(err) + } + if err := svc.CanSend(ctx, c.ID, g.ConversationID); !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("kicked send %v", err) + } + muted := true + if _, err := svc.UpdateMember(ctx, a.ID, g.ID, b.ID, &UpdateMemberInput{SpeakMuted: &muted}); err != nil { + t.Fatal(err) + } + if err := svc.CanSend(ctx, b.ID, g.ConversationID); !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("muted send %v", err) + } + if err := svc.RemoveMember(ctx, b.ID, g.ID, b.ID); err != nil { + t.Fatal(err) + } + if err := svc.Dissolve(ctx, a.ID, g.ID); err != nil { + t.Fatal(err) + } + if _, err := svc.Get(ctx, a.ID, g.ID); !errors.Is(err, errs.ErrNotFound) { + t.Fatalf("dissolved %v", err) + } +} + +func TestGroupAvatarKey(t *testing.T) { + svc, friends, users := testGroups(t) + ctx := t.Context() + a := login(t, users, "test01@spark.com", 1) + b := login(t, users, "test02@spark.com", 2) + becomeFriends(t, friends, a, b) + g, err := svc.Create(ctx, a.ID, &CreateInput{Name: "局", MemberUIDs: []int64{b.ID}}) + if err != nil { + t.Fatal(err) + } + key := "perm/group/1/99.jpg" + if g.ID != 1 { + key = "perm/group/" + strconv.FormatInt(g.ID, 10) + "/99.jpg" + } + if _, err := svc.Update(ctx, a.ID, g.ID, &UpdateInput{Avatar: &key}); err != nil { + t.Fatal(err) + } + got, err := svc.Get(ctx, a.ID, g.ID) + if err != nil || got.Avatar != key { + t.Fatalf("%+v %v", got, err) + } + bad := "perm/avatar/1/x.jpg" + if _, err := svc.Update(ctx, a.ID, g.ID, &UpdateInput{Avatar: &bad}); err == nil { + t.Fatal("personal avatar key") + } +} + +func TestCreateGroupCollageAvatar(t *testing.T) { + store := &memObjects{kv: map[string][]byte{}} + svc, friends, users := testGroupsStore(t, store) + ctx := t.Context() + a := login(t, users, "test01@spark.com", 1) + b := login(t, users, "test02@spark.com", 2) + becomeFriends(t, friends, a, b) + if a.Avatar == "" || b.Avatar == "" { + t.Fatal("register default avatar") + } + got, err := svc.Create(ctx, a.ID, &CreateInput{Name: "拼接头像", MemberUIDs: []int64{b.ID}}) + if err != nil { + t.Fatal(err) + } + prefix := "perm/group/" + strconv.FormatInt(got.ID, 10) + "/" + if !strings.HasPrefix(got.Avatar, prefix) { + t.Fatalf("avatar %q", got.Avatar) + } + raw, ok := store.kv[got.Avatar] + if !ok || len(raw) == 0 { + t.Fatal("missing object") + } + img, err := jpeg.Decode(bytes.NewReader(raw)) + if err != nil || img.Bounds().Dx() < 1 { + t.Fatalf("jpeg %+v %v", img, err) + } +} + +func TestCreateGroupAvatarWhenMemberGetFails(t *testing.T) { + store := &noGetStore{kv: map[string][]byte{}} + svc, friends, users := testGroupsStore(t, store) + ctx := t.Context() + a := login(t, users, "test01@spark.com", 1) + b := login(t, users, "test02@spark.com", 2) + becomeFriends(t, friends, a, b) + got, err := svc.Create(ctx, a.ID, &CreateInput{Name: "默认头像", MemberUIDs: []int64{b.ID}}) + if err != nil { + t.Fatal(err) + } + if got.Avatar == "" || !strings.HasPrefix(got.Avatar, "perm/group/") { + t.Fatalf("want default group avatar, got %q", got.Avatar) + } + if _, ok := store.kv[got.Avatar]; !ok { + t.Fatal("missing object") + } +} + +type memObjects struct { + kv map[string][]byte +} + +func (m *memObjects) Get(_ context.Context, key string) ([]byte, error) { + if m == nil || m.kv == nil { + return nil, errs.ErrNotFound + } + b, ok := m.kv[key] + if !ok { + return nil, errs.ErrNotFound + } + return append([]byte(nil), b...), nil +} + +func (m *memObjects) Put(_ context.Context, key string, body []byte, _ string) error { + if m.kv == nil { + m.kv = map[string][]byte{} + } + m.kv[key] = append([]byte(nil), body...) + return nil +} + +type noGetStore struct { + kv map[string][]byte +} + +func (n *noGetStore) Get(_ context.Context, _ string) ([]byte, error) { + return nil, errs.ErrNotFound +} + +func (n *noGetStore) Put(_ context.Context, key string, body []byte, _ string) error { + if n.kv == nil { + n.kv = map[string][]byte{} + } + n.kv[key] = append([]byte(nil), body...) + return nil +} diff --git a/internal/module/group/logic/invite.go b/internal/module/group/logic/invite.go new file mode 100644 index 0000000..35db369 --- /dev/null +++ b/internal/module/group/logic/invite.go @@ -0,0 +1,81 @@ +package logic + +import ( + "context" + + "spark-server/internal/module/group/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" +) + +type InviteInput struct { + UIDs []int64 `json:"uids" validate:"required,min=1,max=199,dive,gt=0" label:"field.member_uids" errmsg:"required:validate.group.members_required;min:validate.group.members_required;max:validate.group.members_invalid"` +} + +func (s *Service) Invite(ctx context.Context, actorID, groupID int64, in *InviteInput) (*View, error) { + if !s.ready() || actorID <= 0 { + return nil, errs.ErrUnauthorized + } + if in == nil { + return nil, errs.ErrInvalidArgument + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return nil, err + } + g, err := s.loadActive(ctx, groupID) + if err != nil { + return nil, err + } + if _, err := s.requireMember(ctx, groupID, actorID); err != nil { + return nil, err + } + added, err := s.newMembers(ctx, g, actorID, in.UIDs) + if err != nil { + return nil, err + } + if err := s.repo.AddMembers(ctx, groupID, added); err != nil { + return nil, err + } + g.MemberCount += len(added) + s.invalidateMembers(g.ConversationID) + ids := make([]int64, 0, len(added)) + for _, mem := range added { + ids = append(ids, mem.UserID) + } + _ = s.notice(ctx, g, actorID, entity.SystemKindInvite, ids) + mem, err := s.requireMember(ctx, groupID, actorID) + if err != nil { + return viewOf(g, entity.RoleMember), nil + } + return viewOf(g, mem.Role), nil +} + +func (s *Service) newMembers(ctx context.Context, g *entity.Group, actorID int64, uids []int64) ([]*entity.Member, error) { + lang := reqctx.Lang(ctx) + now := nowMS() + added := make([]*entity.Member, 0, len(uids)) + for _, uid := range entity.UniqueUIDs(uids) { + if uid == actorID { + continue + } + if _, err := s.repo.GetMember(ctx, g.ID, uid); err == nil { + continue + } + if err := s.activeUser(ctx, uid); err != nil { + return nil, err + } + if err := s.requireFriend(ctx, actorID, uid); err != nil { + return nil, err + } + added = append(added, &entity.Member{GroupID: g.ID, UserID: uid, Role: entity.RoleMember, JoinedAtMS: now}) + } + if len(added) == 0 { + return nil, errs.ErrDuplicate.WithMsg(i18n.T(lang, i18n.KeyGroupAlreadyMember, nil)) + } + if g.MemberCount+len(added) > entity.MaxMembers { + return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyGroupFull, nil)) + } + return added, nil +} diff --git a/internal/module/group/logic/lookup.go b/internal/module/group/logic/lookup.go new file mode 100644 index 0000000..a06c885 --- /dev/null +++ b/internal/module/group/logic/lookup.go @@ -0,0 +1,74 @@ +package logic + +import ( + "context" + "errors" + + "spark-server/internal/module/group/entity" + userentity "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" +) + +func (s *Service) activeUser(ctx context.Context, id int64) error { + u, err := s.users.Get(ctx, id) + if err != nil { + return err + } + if u == nil || u.Status != userentity.StatusActive { + return errs.ErrNotFound + } + return nil +} + +func (s *Service) loadActive(ctx context.Context, groupID int64) (*entity.Group, error) { + g, err := s.repo.Get(ctx, groupID) + if err != nil { + return nil, err + } + if !g.Active() { + return nil, errs.ErrNotFound.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyGroupDissolved, nil)) + } + return g, nil +} + +func (s *Service) requireMember(ctx context.Context, groupID, userID int64) (*entity.Member, error) { + mem, err := s.repo.GetMember(ctx, groupID, userID) + if err != nil { + if errors.Is(err, errs.ErrNotFound) { + return nil, errs.ErrForbidden.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyGroupNotMember, nil)) + } + return nil, err + } + return mem, nil +} + +func (s *Service) requireManage(ctx context.Context, groupID, userID int64) (*entity.Member, error) { + mem, err := s.requireMember(ctx, groupID, userID) + if err != nil { + return nil, err + } + if !mem.CanManage() { + return nil, errs.ErrForbidden.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyGroupRoleDenied, nil)) + } + return mem, nil +} + +func (s *Service) requireOwner(ctx context.Context, g *entity.Group, userID int64) error { + if g == nil || g.OwnerID != userID { + return errs.ErrForbidden.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyGroupRoleDenied, nil)) + } + return nil +} + +func (s *Service) requireFriend(ctx context.Context, a, b int64) error { + ok, err := s.friends.AreFriends(ctx, a, b) + if err != nil { + return err + } + if !ok { + return errs.ErrForbidden.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyGroupNotFriend, nil)) + } + return nil +} diff --git a/internal/module/group/logic/member_update.go b/internal/module/group/logic/member_update.go new file mode 100644 index 0000000..e7af455 --- /dev/null +++ b/internal/module/group/logic/member_update.go @@ -0,0 +1,81 @@ +package logic + +import ( + "context" + "strings" + + "spark-server/internal/module/group/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" +) + +type UpdateMemberInput struct { + Role *int `json:"role" validate:"omitempty,oneof=2 3" label:"field.role" errmsg:"oneof:validate.group.role_invalid"` + SpeakMuted *bool `json:"speak_muted"` + Nickname *string `json:"nickname" validate:"omitempty,max=32" label:"field.group_nickname" errmsg:"max:validate.group.nickname_invalid"` +} + +func (s *Service) UpdateMember(ctx context.Context, actorID, groupID, targetID int64, in *UpdateMemberInput) (*MemberView, error) { + if !s.ready() || actorID <= 0 { + return nil, errs.ErrUnauthorized + } + if in == nil || targetID <= 0 { + return nil, errs.ErrInvalidArgument + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return nil, err + } + if in.Role == nil && in.SpeakMuted == nil && in.Nickname == nil { + return nil, errs.ErrInvalidArgument + } + g, err := s.loadActive(ctx, groupID) + if err != nil { + return nil, err + } + target, err := s.requireMember(ctx, groupID, targetID) + if err != nil { + return nil, err + } + if err := s.applyMemberPatch(ctx, g, actorID, target, in); err != nil { + return nil, err + } + if err := s.repo.UpdateMember(ctx, target); err != nil { + return nil, err + } + u, _ := s.users.Get(ctx, target.UserID) + return memberViewOf(target, u), nil +} + +func (s *Service) applyMemberPatch(ctx context.Context, g *entity.Group, actorID int64, target *entity.Member, in *UpdateMemberInput) error { + lang := reqctx.Lang(ctx) + self := actorID == target.UserID + if in.Nickname != nil { + if !self { + if _, err := s.requireManage(ctx, g.ID, actorID); err != nil { + return err + } + } + target.Nickname = strings.TrimSpace(*in.Nickname) + } + if in.Role != nil { + if err := s.requireOwner(ctx, g, actorID); err != nil { + return err + } + if self || (*in.Role != entity.RoleAdmin && *in.Role != entity.RoleMember) { + return errs.ErrForbidden.WithMsg(i18n.T(lang, i18n.KeyGroupRoleDenied, nil)) + } + target.Role = *in.Role + } + if in.SpeakMuted != nil { + if _, err := s.requireManage(ctx, g.ID, actorID); err != nil { + return err + } + if target.Role == entity.RoleOwner { + return errs.ErrForbidden.WithMsg(i18n.T(lang, i18n.KeyGroupRoleDenied, nil)) + } + target.SpeakMuted = *in.SpeakMuted + } + return nil +} diff --git a/internal/module/group/logic/members.go b/internal/module/group/logic/members.go new file mode 100644 index 0000000..0a0b77a --- /dev/null +++ b/internal/module/group/logic/members.go @@ -0,0 +1,73 @@ +package logic + +import ( + "context" + + "spark-server/internal/pkg/errs" +) + +func (s *Service) ListMembers(ctx context.Context, userID, groupID int64, limit int) (*MemberList, error) { + if !s.ready() || userID <= 0 { + return nil, errs.ErrUnauthorized + } + if _, err := s.loadActive(ctx, groupID); err != nil { + return nil, err + } + if _, err := s.requireMember(ctx, groupID, userID); err != nil { + return nil, err + } + rows, err := s.repo.ListMembers(ctx, groupID, limit) + if err != nil { + return nil, err + } + items := make([]*MemberView, 0, len(rows)) + for _, mem := range rows { + u, err := s.users.Get(ctx, mem.UserID) + if err != nil { + items = append(items, memberViewOf(mem, nil)) + continue + } + items = append(items, memberViewOf(mem, u)) + } + return &MemberList{Items: items}, nil +} + +func (s *Service) MemberIDs(ctx context.Context, conversationID int64) ([]int64, error) { + if !s.ready() || conversationID <= 0 { + return nil, errs.ErrInvalidArgument + } + if ids, ok := s.cachedMemberIDs(conversationID); ok { + return ids, nil + } + g, err := s.repo.GetByConversation(ctx, conversationID) + if err != nil { + return nil, err + } + if !g.Active() { + return []int64{}, nil + } + ids, err := s.repo.ListMemberIDs(ctx, g.ID) + if err != nil { + return nil, err + } + s.putMemberIDs(conversationID, ids) + return ids, nil +} + +func (s *Service) CanSend(ctx context.Context, userID, conversationID int64) error { + if !s.ready() || userID <= 0 || conversationID <= 0 { + return errs.ErrForbidden + } + g, err := s.repo.GetByConversation(ctx, conversationID) + if err != nil || !g.Active() { + return errs.ErrForbidden + } + mem, err := s.repo.GetMember(ctx, g.ID, userID) + if err != nil { + return errs.ErrForbidden + } + if !mem.CanSpeak(g.MutedAll) { + return errs.ErrForbidden + } + return nil +} diff --git a/internal/module/group/logic/notice.go b/internal/module/group/logic/notice.go new file mode 100644 index 0000000..0acf6a7 --- /dev/null +++ b/internal/module/group/logic/notice.go @@ -0,0 +1,65 @@ +package logic + +import ( + "context" + "encoding/json" + "fmt" + "time" + + deliverentity "spark-server/internal/module/deliver/entity" + "spark-server/internal/module/group/entity" + msgentity "spark-server/internal/module/message/entity" + "spark-server/internal/pkg/reply" +) + +func (s *Service) notice(ctx context.Context, g *entity.Group, actorID int64, kind string, targets []int64) error { + if s.msgs == nil || s.ids == nil || g == nil || g.ConversationID <= 0 { + return nil + } + msgID, err := s.ids.NextID() + if err != nil { + return err + } + now := time.Now() + content := msgentity.NewSystem(&msgentity.System{Kind: kind}) + raw, err := content.Marshal() + if err != nil { + return err + } + msg := &msgentity.Message{ + ID: msgID, ConversationID: g.ConversationID, SenderID: 0, + ClientMsgID: fmt.Sprintf("sys:group:%d", msgID), + Type: msgentity.TypeSystem, Content: raw, + Status: msgentity.StatusNormal, CreatedAtMS: now.UnixMilli(), + } + if err := s.msgs.Create(ctx, msg); err != nil { + return err + } + if err := s.repo.UpdateLastMsg(ctx, g.ConversationID, msgID, now.UnixMilli()); err != nil { + return err + } + if s.queue == nil { + return nil + } + to, err := s.repo.ListMemberIDs(ctx, g.ID) + if err != nil || len(to) == 0 { + to = append([]int64{actorID}, targets...) + } + return s.queue.Push(ctx, &deliverentity.Job{ + MsgID: msgID, Cmd: cmdMessage, ConversationID: g.ConversationID, + FromUserID: actorID, ToUserIDs: to, + Payload: reply.MustMarshal(reply.OK(groupPushBody{ + MsgID: msgID, ConversationID: g.ConversationID, SenderID: 0, + MessageType: int16(msgentity.TypeSystem), Content: raw, CreatedAt: now.UnixMilli(), + })), + }) +} + +type groupPushBody struct { + MsgID int64 `json:"msg_id"` + ConversationID int64 `json:"conversation_id"` + SenderID int64 `json:"sender_id"` + MessageType int16 `json:"message_type"` + Content json.RawMessage `json:"content"` + CreatedAt int64 `json:"created_at"` +} diff --git a/internal/module/group/logic/remove.go b/internal/module/group/logic/remove.go new file mode 100644 index 0000000..e8e440f --- /dev/null +++ b/internal/module/group/logic/remove.go @@ -0,0 +1,59 @@ +package logic + +import ( + "context" + + "spark-server/internal/module/group/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" +) + +func (s *Service) RemoveMember(ctx context.Context, actorID, groupID, targetID int64) error { + if !s.ready() || actorID <= 0 { + return errs.ErrUnauthorized + } + if targetID <= 0 { + return errs.ErrInvalidArgument + } + g, err := s.loadActive(ctx, groupID) + if err != nil { + return err + } + target, err := s.repo.GetMember(ctx, groupID, targetID) + if err != nil { + return err + } + if actorID == targetID { + return s.leave(ctx, g, target) + } + return s.kick(ctx, g, actorID, target) +} + +func (s *Service) leave(ctx context.Context, g *entity.Group, self *entity.Member) error { + if self.Role == entity.RoleOwner { + return errs.ErrForbidden.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyGroupOwnerLeave, nil)) + } + if err := s.repo.RemoveMember(ctx, g.ID, self.UserID); err != nil { + return err + } + s.invalidateMembers(g.ConversationID) + _ = s.notice(ctx, g, self.UserID, entity.SystemKindLeave, []int64{self.UserID}) + return nil +} + +func (s *Service) kick(ctx context.Context, g *entity.Group, actorID int64, target *entity.Member) error { + actor, err := s.requireManage(ctx, g.ID, actorID) + if err != nil { + return err + } + if target.Role == entity.RoleOwner || (target.Role == entity.RoleAdmin && actor.Role != entity.RoleOwner) { + return errs.ErrForbidden.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyGroupRoleDenied, nil)) + } + if err := s.repo.RemoveMember(ctx, g.ID, target.UserID); err != nil { + return err + } + s.invalidateMembers(g.ConversationID) + _ = s.notice(ctx, g, actorID, entity.SystemKindKick, []int64{target.UserID}) + return nil +} diff --git a/internal/module/group/logic/service.go b/internal/module/group/logic/service.go new file mode 100644 index 0000000..97da5b1 --- /dev/null +++ b/internal/module/group/logic/service.go @@ -0,0 +1,105 @@ +package logic + +import ( + "context" + "sync" + "time" + + deliverentity "spark-server/internal/module/deliver/entity" + "spark-server/internal/module/group/entity" + msgentity "spark-server/internal/module/message/entity" + userentity "spark-server/internal/module/user/entity" +) + +const memberCacheTTL = 3 * time.Second + +const cmdMessage uint16 = 6 + +// Repository is group persistence. Model implements it; do not split this by table. +type Repository interface { + CreateGroup(ctx context.Context, g *entity.Group, members []*entity.Member) error + Get(ctx context.Context, id int64) (*entity.Group, error) + GetByConversation(ctx context.Context, conversationID int64) (*entity.Group, error) + ListByUser(ctx context.Context, userID int64, limit int) ([]*entity.Group, error) + UpdateProfile(ctx context.Context, g *entity.Group) error + GetMember(ctx context.Context, groupID, userID int64) (*entity.Member, error) + ListMembers(ctx context.Context, groupID int64, limit int) ([]*entity.Member, error) + ListMemberIDs(ctx context.Context, groupID int64) ([]int64, error) + AddMembers(ctx context.Context, groupID int64, members []*entity.Member) error + RemoveMember(ctx context.Context, groupID, userID int64) error + UpdateMember(ctx context.Context, mem *entity.Member) error + TransferOwner(ctx context.Context, groupID, fromID, toID int64) error + Dissolve(ctx context.Context, groupID int64) error + UpdateLastMsg(ctx context.Context, conversationID, msgID, atMS int64) error +} + +type Users interface { + Get(ctx context.Context, id int64) (*userentity.User, error) +} + +type Friendships interface { + AreFriends(ctx context.Context, a, b int64) (bool, error) +} + +type IDGen interface { + NextID() (int64, error) +} + +type Messages interface { + Create(ctx context.Context, m *msgentity.Message) error +} + +type DeliverQueue interface { + Push(ctx context.Context, job *deliverentity.Job) error +} + +type Objects interface { + Get(ctx context.Context, key string) ([]byte, error) + Put(ctx context.Context, key string, body []byte, contentType string) error +} + +type memberCacheEntry struct { + ids []int64 + at time.Time +} + +// Service is the group use-case layer (HTTP and TCP share it). +type Service struct { + repo Repository + users Users + friends Friendships + ids IDGen + msgs Messages + queue DeliverQueue + objects Objects + cache sync.Map +} + +func NewService(repo Repository, users Users, friends Friendships, ids IDGen, msgs Messages, queue DeliverQueue, objects Objects) *Service { + return &Service{repo: repo, users: users, friends: friends, ids: ids, msgs: msgs, queue: queue, objects: objects} +} + +func (s *Service) ready() bool { + return s != nil && s.repo != nil && s.users != nil && s.friends != nil && s.ids != nil +} + +func (s *Service) cachedMemberIDs(groupID int64) ([]int64, bool) { + v, ok := s.cache.Load(groupID) + if !ok { + return nil, false + } + e, ok := v.(memberCacheEntry) + if !ok || time.Since(e.at) > memberCacheTTL { + s.cache.Delete(groupID) + return nil, false + } + return append([]int64(nil), e.ids...), true +} + +func (s *Service) putMemberIDs(groupID int64, ids []int64) { + s.cache.Store(groupID, memberCacheEntry{ids: append([]int64(nil), ids...), at: time.Now()}) +} + +func (s *Service) invalidateMembers(groupID int64) { + s.cache.Delete(groupID) +} diff --git a/internal/module/group/logic/transfer.go b/internal/module/group/logic/transfer.go new file mode 100644 index 0000000..939e9d2 --- /dev/null +++ b/internal/module/group/logic/transfer.go @@ -0,0 +1,66 @@ +package logic + +import ( + "context" + + "spark-server/internal/module/group/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" +) + +type TransferInput struct { + UID int64 `json:"uid" validate:"required,gt=0" label:"field.uid" errmsg:"required:validate.group.uid_required;gt:validate.group.uid_required"` +} + +func (s *Service) Transfer(ctx context.Context, ownerID, groupID int64, in *TransferInput) (*View, error) { + if !s.ready() || ownerID <= 0 { + return nil, errs.ErrUnauthorized + } + if in == nil { + return nil, errs.ErrInvalidArgument + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return nil, err + } + g, err := s.loadActive(ctx, groupID) + if err != nil { + return nil, err + } + if err := s.requireOwner(ctx, g, ownerID); err != nil { + return nil, err + } + if in.UID == ownerID { + return nil, errs.ErrInvalidArgument + } + if _, err := s.requireMember(ctx, groupID, in.UID); err != nil { + return nil, err + } + if err := s.repo.TransferOwner(ctx, groupID, ownerID, in.UID); err != nil { + return nil, err + } + g.OwnerID = in.UID + s.invalidateMembers(g.ConversationID) + _ = s.notice(ctx, g, ownerID, entity.SystemKindTransfer, []int64{in.UID}) + return viewOf(g, entity.RoleMember), nil +} + +func (s *Service) Dissolve(ctx context.Context, ownerID, groupID int64) error { + if !s.ready() || ownerID <= 0 { + return errs.ErrUnauthorized + } + g, err := s.loadActive(ctx, groupID) + if err != nil { + return err + } + if err := s.requireOwner(ctx, g, ownerID); err != nil { + return err + } + ids, _ := s.repo.ListMemberIDs(ctx, groupID) + if err := s.repo.Dissolve(ctx, groupID); err != nil { + return err + } + s.invalidateMembers(g.ConversationID) + _ = s.notice(ctx, g, ownerID, entity.SystemKindDissolve, ids) + return nil +} diff --git a/internal/module/group/logic/update.go b/internal/module/group/logic/update.go new file mode 100644 index 0000000..6c21c74 --- /dev/null +++ b/internal/module/group/logic/update.go @@ -0,0 +1,60 @@ +package logic + +import ( + "context" + "strings" + + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" +) + +type UpdateInput struct { + Name *string `json:"name" validate:"omitempty,min=1,max=32" label:"field.group_name" errmsg:"min:validate.group.name_invalid;max:validate.group.name_invalid"` + Avatar *string `json:"avatar" validate:"omitempty,max=512" label:"field.avatar"` + MutedAll *bool `json:"muted_all"` +} + +func (s *Service) Update(ctx context.Context, userID, groupID int64, in *UpdateInput) (*View, error) { + if !s.ready() || userID <= 0 { + return nil, errs.ErrUnauthorized + } + if in == nil { + return nil, errs.ErrInvalidArgument + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return nil, err + } + if in.Name == nil && in.Avatar == nil && in.MutedAll == nil { + return nil, errs.ErrInvalidArgument + } + g, err := s.loadActive(ctx, groupID) + if err != nil { + return nil, err + } + if _, err := s.requireManage(ctx, groupID, userID); err != nil { + return nil, err + } + if in.Name != nil { + g.Name = strings.TrimSpace(*in.Name) + } + if in.Avatar != nil { + av := strings.TrimSpace(*in.Avatar) + if !validGroupAvatar(groupID, av) { + return nil, errs.ErrInvalidArgument + } + g.Avatar = av + } + if in.MutedAll != nil { + g.MutedAll = *in.MutedAll + } + g.UpdatedAtMS = nowMS() + if err := s.repo.UpdateProfile(ctx, g); err != nil { + return nil, err + } + mem, err := s.requireMember(ctx, groupID, userID) + if err != nil { + return nil, err + } + return viewOf(g, mem.Role), nil +} diff --git a/internal/module/group/logic/view.go b/internal/module/group/logic/view.go new file mode 100644 index 0000000..2a2df18 --- /dev/null +++ b/internal/module/group/logic/view.go @@ -0,0 +1,75 @@ +package logic + +import ( + "time" + + "spark-server/internal/module/group/entity" + userentity "spark-server/internal/module/user/entity" +) + +// View is the HTTP/TCP group card. ID is the AUTO_INCREMENT 群号. +type View struct { + ID int64 `json:"id"` + ConversationID int64 `json:"conversation_id"` + Name string `json:"name"` + Avatar string `json:"avatar"` + OwnerID int64 `json:"owner_id"` + MutedAll bool `json:"muted_all"` + MemberCount int `json:"member_count"` + Role int `json:"role"` + CreatedAt int64 `json:"created_at"` +} + +type MemberView struct { + UID int64 `json:"uid"` + SparkID string `json:"spark_id"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Gender int `json:"gender"` + Role int `json:"role"` + GroupNickname string `json:"group_nickname"` + SpeakMuted bool `json:"speak_muted"` + JoinedAt int64 `json:"joined_at"` +} + +type List struct { + Items []*View `json:"items"` +} + +type MemberList struct { + Items []*MemberView `json:"items"` +} + +func viewOf(g *entity.Group, role int) *View { + if g == nil { + return nil + } + return &View{ + ID: g.ID, ConversationID: g.ConversationID, Name: g.Name, Avatar: g.Avatar, + OwnerID: g.OwnerID, MutedAll: g.MutedAll, MemberCount: g.MemberCount, + Role: role, CreatedAt: g.CreatedAtMS, + } +} + +func memberViewOf(mem *entity.Member, u *userentity.User) *MemberView { + v := &MemberView{} + if mem != nil { + v.UID = mem.UserID + v.Role = mem.Role + v.GroupNickname = mem.Nickname + v.SpeakMuted = mem.SpeakMuted + v.JoinedAt = mem.JoinedAtMS + } + if u != nil { + v.SparkID = u.SparkID + v.Nickname = u.Nickname + v.Avatar = u.Avatar + v.Gender = u.Gender + if v.UID == 0 { + v.UID = u.ID + } + } + return v +} + +func nowMS() int64 { return time.Now().UnixMilli() } diff --git a/internal/module/group/model/group.go b/internal/module/group/model/group.go new file mode 100644 index 0000000..ef6e347 --- /dev/null +++ b/internal/module/group/model/group.go @@ -0,0 +1,167 @@ +package model + +import ( + "context" + "time" + + "spark-server/internal/module/group/entity" + "spark-server/internal/pkg/errs" +) + +// Group maps table groups. +type Group struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:群号,表内自增,对外群 ID"` + ConversationID int64 `gorm:"column:conversation_id;uniqueIndex;comment:对应 conversations.id(Snowflake),发消息与同步用"` + Name string `gorm:"column:name;comment:群名称"` + Avatar string `gorm:"column:avatar;comment:群头像对象存储 key,空表示未设置"` + OwnerID int64 `gorm:"column:owner_id;comment:群主 user_id"` + MutedAll int `gorm:"column:muted_all;comment:0=未全员禁言 1=全员禁言(群主/管理员仍可发言)"` + MemberCount int `gorm:"column:member_count;comment:当前成员数,含群主"` + Status int `gorm:"column:status;comment:1=正常 2=已解散"` + CreatedAt time.Time `gorm:"column:created_at;comment:创建时间"` + UpdatedAt time.Time `gorm:"column:updated_at;comment:资料或成员变更时间"` +} + +func (Group) TableName() string { return "groups" } + +func (r *Group) entity() *entity.Group { + if r == nil { + return nil + } + g := &entity.Group{ + ID: r.ID, ConversationID: r.ConversationID, Name: r.Name, Avatar: r.Avatar, OwnerID: r.OwnerID, + MemberCount: r.MemberCount, Status: r.Status, + CreatedAtMS: r.CreatedAt.UnixMilli(), UpdatedAtMS: r.UpdatedAt.UnixMilli(), + } + g.MutedAll = r.MutedAll != 0 + return g +} + +func groupRow(g *entity.Group) *Group { + now := msTime(g.CreatedAtMS) + updated := msTime(g.UpdatedAtMS) + if g.UpdatedAtMS <= 0 { + updated = now + } + row := &Group{ + ID: g.ID, ConversationID: g.ConversationID, Name: g.Name, Avatar: g.Avatar, OwnerID: g.OwnerID, + MemberCount: g.MemberCount, Status: g.Status, + CreatedAt: now, UpdatedAt: updated, + } + if g.MutedAll { + row.MutedAll = 1 + } + if row.Status == 0 { + row.Status = entity.StatusActive + } + return row +} + +func (m *Repo) Get(ctx context.Context, id int64) (*entity.Group, error) { + if id <= 0 { + return nil, errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row Group + if err := db.Where("id = ?", id).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.entity(), nil +} + +func (m *Repo) GetByConversation(ctx context.Context, conversationID int64) (*entity.Group, error) { + if conversationID <= 0 { + return nil, errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row Group + if err := db.Where("conversation_id = ?", conversationID).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.entity(), nil +} + +func (m *Repo) ListByUser(ctx context.Context, userID int64, limit int) ([]*entity.Group, error) { + if userID <= 0 { + return nil, errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + limit = entity.ClampLimit(limit) + var rows []Group + err = db.Table("groups AS g").Select("g.*"). + Joins("INNER JOIN group_members m ON m.group_id = g.id"). + Where("m.user_id = ? AND g.status = ?", userID, entity.StatusActive). + Order("g.updated_at DESC").Limit(limit).Find(&rows).Error + if err != nil { + return nil, mapDBErr(err) + } + out := make([]*entity.Group, 0, len(rows)) + for i := range rows { + out = append(out, rows[i].entity()) + } + return out, nil +} + +func (m *Repo) UpdateProfile(ctx context.Context, g *entity.Group) error { + if g == nil || g.ID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + res := db.Model(&Group{}).Where("id = ? AND status = ?", g.ID, entity.StatusActive).Updates(map[string]any{ + "name": g.Name, + "avatar": g.Avatar, + "muted_all": boolInt(g.MutedAll), + "updated_at": msTime(g.UpdatedAtMS), + }) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return nil +} + +func (m *Repo) UpdateLastMsg(ctx context.Context, conversationID, msgID, atMS int64) error { + db, err := m.session(ctx) + if err != nil { + return err + } + res := db.Table("conversations").Where("id = ?", conversationID).Updates(map[string]any{ + "last_msg_id": msgID, + "last_msg_at": msTime(atMS), + }) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return nil +} + +func msTime(ms int64) time.Time { + if ms <= 0 { + return time.Now() + } + return time.UnixMilli(ms) +} + +func boolInt(v bool) int { + if v { + return 1 + } + return 0 +} diff --git a/internal/module/group/model/group_member.go b/internal/module/group/model/group_member.go new file mode 100644 index 0000000..c031004 --- /dev/null +++ b/internal/module/group/model/group_member.go @@ -0,0 +1,121 @@ +package model + +import ( + "context" + "time" + + "spark-server/internal/module/group/entity" + "spark-server/internal/pkg/errs" +) + +// GroupMember maps table group_members. +type GroupMember struct { + GroupID int64 `gorm:"column:group_id;primaryKey;comment:群号,groups.id"` + UserID int64 `gorm:"column:user_id;primaryKey;comment:成员 user_id"` + Role int `gorm:"column:role;comment:1=群主 2=管理员 3=普通成员"` + Nickname string `gorm:"column:nickname;comment:群内昵称,空则用用户昵称"` + SpeakMuted int `gorm:"column:speak_muted;comment:0=未禁言 1=被单独禁言"` + JoinedAt time.Time `gorm:"column:joined_at;comment:入群时间"` +} + +func (GroupMember) TableName() string { return "group_members" } + +func (r *GroupMember) entity() *entity.Member { + if r == nil { + return nil + } + m := &entity.Member{ + GroupID: r.GroupID, UserID: r.UserID, Role: r.Role, + Nickname: r.Nickname, JoinedAtMS: r.JoinedAt.UnixMilli(), + } + m.SpeakMuted = r.SpeakMuted != 0 + return m +} + +func memberRow(m *entity.Member) *GroupMember { + row := &GroupMember{ + GroupID: m.GroupID, UserID: m.UserID, Role: m.Role, + Nickname: m.Nickname, JoinedAt: msTime(m.JoinedAtMS), + } + if m.SpeakMuted { + row.SpeakMuted = 1 + } + if row.Role == 0 { + row.Role = entity.RoleMember + } + return row +} + +func (m *Repo) GetMember(ctx context.Context, groupID, userID int64) (*entity.Member, error) { + if groupID <= 0 || userID <= 0 { + return nil, errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row GroupMember + if err := db.Where("group_id = ? AND user_id = ?", groupID, userID).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.entity(), nil +} + +func (m *Repo) ListMembers(ctx context.Context, groupID int64, limit int) ([]*entity.Member, error) { + if groupID <= 0 { + return nil, errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + limit = entity.ClampMemberLimit(limit) + var rows []GroupMember + err = db.Where("group_id = ?", groupID).Order("role ASC, joined_at ASC").Limit(limit).Find(&rows).Error + if err != nil { + return nil, mapDBErr(err) + } + out := make([]*entity.Member, 0, len(rows)) + for i := range rows { + out = append(out, rows[i].entity()) + } + return out, nil +} + +func (m *Repo) ListMemberIDs(ctx context.Context, groupID int64) ([]int64, error) { + if groupID <= 0 { + return nil, errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var ids []int64 + err = db.Model(&GroupMember{}).Where("group_id = ?", groupID).Order("joined_at ASC").Pluck("user_id", &ids).Error + if err != nil { + return nil, mapDBErr(err) + } + return ids, nil +} + +func (m *Repo) UpdateMember(ctx context.Context, mem *entity.Member) error { + if mem == nil || mem.GroupID <= 0 || mem.UserID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + res := db.Model(&GroupMember{}).Where("group_id = ? AND user_id = ?", mem.GroupID, mem.UserID).Updates(map[string]any{ + "role": mem.Role, + "nickname": mem.Nickname, + "speak_muted": boolInt(mem.SpeakMuted), + }) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return nil +} diff --git a/internal/module/group/model/memory.go b/internal/module/group/model/memory.go new file mode 100644 index 0000000..a30d014 --- /dev/null +++ b/internal/module/group/model/memory.go @@ -0,0 +1,190 @@ +package model + +import ( + "context" + "sort" + "sync" + "time" + + "spark-server/internal/module/group/entity" + "spark-server/internal/pkg/errs" +) + +type lastMsg struct { + ID int64 + At int64 +} + +// MemoryRepo is an in-process group store for tests and empty-DSN bring-up. +type MemoryRepo struct { + mu sync.Mutex + nextID int64 + groups map[int64]*entity.Group + byConv map[int64]int64 + members map[int64]map[int64]*entity.Member + last map[int64]lastMsg +} + +func NewMemoryRepo() *MemoryRepo { + return &MemoryRepo{ + groups: make(map[int64]*entity.Group), + byConv: make(map[int64]int64), + members: make(map[int64]map[int64]*entity.Member), + last: make(map[int64]lastMsg), + } +} + +func (m *MemoryRepo) Get(_ context.Context, id int64) (*entity.Group, error) { + m.mu.Lock() + defer m.mu.Unlock() + return copyGroup(m.groups[id]) +} + +func (m *MemoryRepo) GetByConversation(_ context.Context, conversationID int64) (*entity.Group, error) { + m.mu.Lock() + defer m.mu.Unlock() + id, ok := m.byConv[conversationID] + if !ok { + return nil, errs.ErrNotFound + } + return copyGroup(m.groups[id]) +} + +func (m *MemoryRepo) ListByUser(_ context.Context, userID int64, limit int) ([]*entity.Group, error) { + m.mu.Lock() + defer m.mu.Unlock() + limit = entity.ClampLimit(limit) + out := make([]*entity.Group, 0) + for gid, byUser := range m.members { + if _, ok := byUser[userID]; !ok { + continue + } + g, ok := m.groups[gid] + if !ok || g.Status != entity.StatusActive { + continue + } + cp := *g + out = append(out, &cp) + } + sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAtMS > out[j].UpdatedAtMS }) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (m *MemoryRepo) GetMember(_ context.Context, groupID, userID int64) (*entity.Member, error) { + m.mu.Lock() + defer m.mu.Unlock() + byUser, ok := m.members[groupID] + if !ok { + return nil, errs.ErrNotFound + } + mem, ok := byUser[userID] + if !ok { + return nil, errs.ErrNotFound + } + cp := *mem + return &cp, nil +} + +func (m *MemoryRepo) ListMembers(_ context.Context, groupID int64, limit int) ([]*entity.Member, error) { + m.mu.Lock() + defer m.mu.Unlock() + byUser, ok := m.members[groupID] + if !ok { + return nil, errs.ErrNotFound + } + limit = entity.ClampMemberLimit(limit) + out := make([]*entity.Member, 0, len(byUser)) + for _, mem := range byUser { + cp := *mem + out = append(out, &cp) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Role != out[j].Role { + return out[i].Role < out[j].Role + } + return out[i].JoinedAtMS < out[j].JoinedAtMS + }) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (m *MemoryRepo) ListMemberIDs(_ context.Context, groupID int64) ([]int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + byUser, ok := m.members[groupID] + if !ok { + return nil, errs.ErrNotFound + } + ids := make([]int64, 0, len(byUser)) + for uid := range byUser { + ids = append(ids, uid) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids, nil +} + +func (m *MemoryRepo) UpdateProfile(_ context.Context, g *entity.Group) error { + if g == nil { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + cur, ok := m.groups[g.ID] + if !ok || cur.Status != entity.StatusActive { + return errs.ErrNotFound + } + cur.Name = g.Name + cur.Avatar = g.Avatar + cur.MutedAll = g.MutedAll + cur.UpdatedAtMS = g.UpdatedAtMS + if cur.UpdatedAtMS <= 0 { + cur.UpdatedAtMS = time.Now().UnixMilli() + } + return nil +} + +func (m *MemoryRepo) UpdateMember(_ context.Context, mem *entity.Member) error { + if mem == nil { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + byUser, ok := m.members[mem.GroupID] + if !ok { + return errs.ErrNotFound + } + cur, ok := byUser[mem.UserID] + if !ok { + return errs.ErrNotFound + } + cur.Role = mem.Role + cur.Nickname = mem.Nickname + cur.SpeakMuted = mem.SpeakMuted + return nil +} + +func (m *MemoryRepo) UpdateLastMsg(_ context.Context, conversationID, msgID, atMS int64) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.byConv[conversationID]; !ok { + return errs.ErrNotFound + } + if atMS <= 0 { + atMS = time.Now().UnixMilli() + } + m.last[conversationID] = lastMsg{ID: msgID, At: atMS} + return nil +} + +func copyGroup(g *entity.Group) (*entity.Group, error) { + if g == nil { + return nil, errs.ErrNotFound + } + cp := *g + return &cp, nil +} diff --git a/internal/module/group/model/memory_write.go b/internal/module/group/model/memory_write.go new file mode 100644 index 0000000..680724f --- /dev/null +++ b/internal/module/group/model/memory_write.go @@ -0,0 +1,149 @@ +package model + +import ( + "context" + "time" + + "spark-server/internal/module/group/entity" + "spark-server/internal/pkg/errs" +) + +func (m *MemoryRepo) CreateGroup(_ context.Context, g *entity.Group, members []*entity.Member) error { + if g == nil || g.ConversationID <= 0 || len(members) == 0 { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.byConv[g.ConversationID]; ok { + return errs.ErrDuplicate + } + m.nextID++ + cp := *g + cp.ID = m.nextID + cp.MemberCount = len(members) + if cp.Status == 0 { + cp.Status = entity.StatusActive + } + if cp.CreatedAtMS <= 0 { + cp.CreatedAtMS = time.Now().UnixMilli() + } + if cp.UpdatedAtMS <= 0 { + cp.UpdatedAtMS = cp.CreatedAtMS + } + m.groups[cp.ID] = &cp + m.byConv[cp.ConversationID] = cp.ID + g.ID = cp.ID + byUser := make(map[int64]*entity.Member, len(members)) + for _, mem := range members { + if mem == nil || mem.UserID <= 0 { + continue + } + row := *mem + row.GroupID = cp.ID + if row.JoinedAtMS <= 0 { + row.JoinedAtMS = cp.CreatedAtMS + } + if row.Role == 0 { + row.Role = entity.RoleMember + } + byUser[row.UserID] = &row + } + m.members[cp.ID] = byUser + return nil +} + +func (m *MemoryRepo) AddMembers(_ context.Context, groupID int64, members []*entity.Member) error { + if groupID <= 0 || len(members) == 0 { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + g, ok := m.groups[groupID] + if !ok || g.Status != entity.StatusActive { + return errs.ErrNotFound + } + byUser := m.members[groupID] + if byUser == nil { + byUser = make(map[int64]*entity.Member) + m.members[groupID] = byUser + } + now := time.Now().UnixMilli() + for _, mem := range members { + if mem == nil || mem.UserID <= 0 { + continue + } + if _, ok := byUser[mem.UserID]; ok { + return errs.ErrDuplicate + } + row := *mem + row.GroupID = groupID + if row.JoinedAtMS <= 0 { + row.JoinedAtMS = now + } + if row.Role == 0 { + row.Role = entity.RoleMember + } + byUser[row.UserID] = &row + } + g.MemberCount = len(byUser) + g.UpdatedAtMS = now + return nil +} + +func (m *MemoryRepo) RemoveMember(_ context.Context, groupID, userID int64) error { + m.mu.Lock() + defer m.mu.Unlock() + g, ok := m.groups[groupID] + if !ok || g.Status != entity.StatusActive { + return errs.ErrNotFound + } + byUser := m.members[groupID] + if byUser == nil { + return errs.ErrNotFound + } + if _, ok := byUser[userID]; !ok { + return errs.ErrNotFound + } + delete(byUser, userID) + g.MemberCount = len(byUser) + g.UpdatedAtMS = time.Now().UnixMilli() + return nil +} + +func (m *MemoryRepo) Dissolve(_ context.Context, groupID int64) error { + m.mu.Lock() + defer m.mu.Unlock() + g, ok := m.groups[groupID] + if !ok || g.Status != entity.StatusActive { + return errs.ErrNotFound + } + g.Status = entity.StatusDissolved + g.MemberCount = 0 + g.UpdatedAtMS = time.Now().UnixMilli() + delete(m.members, groupID) + return nil +} + +func (m *MemoryRepo) TransferOwner(_ context.Context, groupID, fromID, toID int64) error { + m.mu.Lock() + defer m.mu.Unlock() + g, ok := m.groups[groupID] + if !ok || g.Status != entity.StatusActive || g.OwnerID != fromID { + return errs.ErrNotFound + } + byUser := m.members[groupID] + from, ok := byUser[fromID] + if !ok { + return errs.ErrNotFound + } + to, ok := byUser[toID] + if !ok { + return errs.ErrNotFound + } + from.Role = entity.RoleMember + to.Role = entity.RoleOwner + to.SpeakMuted = false + g.OwnerID = toID + g.UpdatedAtMS = time.Now().UnixMilli() + return nil +} diff --git a/internal/module/group/model/repo.go b/internal/module/group/model/repo.go new file mode 100644 index 0000000..8eae8c4 --- /dev/null +++ b/internal/module/group/model/repo.go @@ -0,0 +1,41 @@ +package model + +import ( + "context" + "errors" + + mysqldriver "github.com/go-sql-driver/mysql" + "gorm.io/gorm" + + "spark-server/internal/pkg/errs" +) + +// Repo is MySQL persistence for groups / group_members (and conversation rows in the same txn). +type Repo struct { + gdb *gorm.DB +} + +func NewRepo(gdb *gorm.DB) *Repo { + return &Repo{gdb: gdb} +} + +func (m *Repo) session(ctx context.Context) (*gorm.DB, error) { + if m == nil || m.gdb == nil { + return nil, errs.ErrAvailable + } + return m.gdb.WithContext(ctx), nil +} + +func mapDBErr(err error) error { + if err == nil { + return nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return errs.ErrNotFound + } + var me *mysqldriver.MySQLError + if errors.As(err, &me) && me.Number == 1062 { + return errs.ErrDuplicate + } + return errs.ErrInner.WithError("group-mysql", err) +} diff --git a/internal/module/group/model/write.go b/internal/module/group/model/write.go new file mode 100644 index 0000000..be8fbf3 --- /dev/null +++ b/internal/module/group/model/write.go @@ -0,0 +1,210 @@ +package model + +import ( + "context" + "time" + + "gorm.io/gorm" + + conventity "spark-server/internal/module/conversation/entity" + convmodel "spark-server/internal/module/conversation/model" + "spark-server/internal/module/group/entity" + "spark-server/internal/pkg/errs" +) + +func (m *Repo) CreateGroup(ctx context.Context, g *entity.Group, members []*entity.Member) error { + if g == nil || g.ConversationID <= 0 || len(members) == 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + now := msTime(g.CreatedAtMS) + return db.Transaction(func(tx *gorm.DB) error { + conv := convmodel.Conversation{ + ID: g.ConversationID, Type: conventity.TypeGroup, LastMsgAt: now, CreatedAt: now, + } + if err := tx.Create(&conv).Error; err != nil { + return mapDBErr(err) + } + g.ID = 0 + g.MemberCount = len(members) + row := groupRow(g) + if err := tx.Create(row).Error; err != nil { + return mapDBErr(err) + } + g.ID = row.ID + for _, mem := range members { + if mem == nil { + continue + } + mem.GroupID = g.ID + if err := insertMember(tx, mem, g.ConversationID, now); err != nil { + return err + } + } + return nil + }) +} + +func (m *Repo) AddMembers(ctx context.Context, groupID int64, members []*entity.Member) error { + if groupID <= 0 || len(members) == 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + now := time.Now() + return db.Transaction(func(tx *gorm.DB) error { + g, err := takeActiveGroup(tx, groupID) + if err != nil { + return err + } + for _, mem := range members { + if mem == nil { + continue + } + mem.GroupID = groupID + if err := insertMember(tx, mem, g.ConversationID, now); err != nil { + return err + } + } + return bumpMemberCount(tx, groupID, len(members), now) + }) +} + +func (m *Repo) RemoveMember(ctx context.Context, groupID, userID int64) error { + if groupID <= 0 || userID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + now := time.Now() + return db.Transaction(func(tx *gorm.DB) error { + g, err := takeActiveGroup(tx, groupID) + if err != nil { + return err + } + res := tx.Where("group_id = ? AND user_id = ?", groupID, userID).Delete(&GroupMember{}) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + if err := tx.Where("conversation_id = ? AND user_id = ?", g.ConversationID, userID). + Delete(&convmodel.ConversationMember{}).Error; err != nil { + return mapDBErr(err) + } + return bumpMemberCount(tx, groupID, -1, now) + }) +} + +func (m *Repo) Dissolve(ctx context.Context, groupID int64) error { + if groupID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + now := time.Now() + return db.Transaction(func(tx *gorm.DB) error { + g, err := takeActiveGroup(tx, groupID) + if err != nil { + return err + } + res := tx.Model(&Group{}).Where("id = ? AND status = ?", groupID, entity.StatusActive).Updates(map[string]any{ + "status": entity.StatusDissolved, + "member_count": 0, + "updated_at": now, + }) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + if err := tx.Where("group_id = ?", groupID).Delete(&GroupMember{}).Error; err != nil { + return mapDBErr(err) + } + if err := tx.Where("conversation_id = ?", g.ConversationID).Delete(&convmodel.ConversationMember{}).Error; err != nil { + return mapDBErr(err) + } + return nil + }) +} + +func (m *Repo) TransferOwner(ctx context.Context, groupID, fromID, toID int64) error { + if groupID <= 0 || fromID <= 0 || toID <= 0 || fromID == toID { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + now := time.Now() + return db.Transaction(func(tx *gorm.DB) error { + res := tx.Model(&Group{}).Where("id = ? AND owner_id = ? AND status = ?", groupID, fromID, entity.StatusActive). + Updates(map[string]any{"owner_id": toID, "updated_at": now}) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + if err := tx.Model(&GroupMember{}).Where("group_id = ? AND user_id = ?", groupID, fromID). + Update("role", entity.RoleMember).Error; err != nil { + return mapDBErr(err) + } + res = tx.Model(&GroupMember{}).Where("group_id = ? AND user_id = ?", groupID, toID). + Updates(map[string]any{"role": entity.RoleOwner, "speak_muted": 0}) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return nil + }) +} + +func takeActiveGroup(tx *gorm.DB, groupID int64) (*Group, error) { + var row Group + if err := tx.Where("id = ? AND status = ?", groupID, entity.StatusActive).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return &row, nil +} + +func insertMember(tx *gorm.DB, mem *entity.Member, conversationID int64, now time.Time) error { + if mem.JoinedAtMS <= 0 { + mem.JoinedAtMS = now.UnixMilli() + } + if err := tx.Create(memberRow(mem)).Error; err != nil { + return mapDBErr(err) + } + row := convmodel.ConversationMember{ + ConversationID: conversationID, UserID: mem.UserID, JoinedAt: msTime(mem.JoinedAtMS), + } + return mapDBErr(tx.Create(&row).Error) +} + +func bumpMemberCount(tx *gorm.DB, groupID int64, delta int, now time.Time) error { + res := tx.Model(&Group{}).Where("id = ? AND status = ?", groupID, entity.StatusActive). + Updates(map[string]any{ + "member_count": gorm.Expr("member_count + ?", delta), + "updated_at": now, + }) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return nil +} diff --git a/internal/module/group/service.go b/internal/module/group/service.go deleted file mode 100644 index cbe4309..0000000 --- a/internal/module/group/service.go +++ /dev/null @@ -1,8 +0,0 @@ -package group - -import "context" - -type Service interface { - CanSend(ctx context.Context, userID, groupID int64) error - MemberIDs(ctx context.Context, groupID int64) ([]int64, error) -} diff --git a/internal/module/inbox/entity/preview.go b/internal/module/inbox/entity/preview.go new file mode 100644 index 0000000..b4275fa --- /dev/null +++ b/internal/module/inbox/entity/preview.go @@ -0,0 +1,8 @@ +// Package entity holds inbox preview types. List/unread live in Redis, not MySQL. +package entity + +type Preview struct { + ConversationID int64 + MsgID int64 + Snippet []byte +} diff --git a/internal/module/inbox/store.go b/internal/module/inbox/logic/store.go similarity index 80% rename from internal/module/inbox/store.go rename to internal/module/inbox/logic/store.go index ebcaafe..3438ff0 100644 --- a/internal/module/inbox/store.go +++ b/internal/module/inbox/logic/store.go @@ -1,8 +1,8 @@ -package inbox +// Package logic is the Redis inbox (conversation list) contract. No MySQL on this hot path. +package logic import "context" -// Store is the Redis ZSET conversation list. MySQL is not on this hot path. type Store interface { TouchUnread(ctx context.Context, userID, conversationID int64, score int64) error TouchRead(ctx context.Context, userID, conversationID int64, score int64) error diff --git a/internal/module/message/entity/content.go b/internal/module/message/entity/content.go new file mode 100644 index 0000000..66ad0d5 --- /dev/null +++ b/internal/module/message/entity/content.go @@ -0,0 +1,298 @@ +package entity + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "unicode/utf8" +) + +// Content is the in-memory message body. Persist only Marshal() bytes into messages.content. +type Content struct { + Type Type `json:"t"` + Text *Text `json:"text,omitempty"` + Image *Image `json:"image,omitempty"` + Voice *Voice `json:"voice,omitempty"` + Video *Video `json:"video,omitempty"` + Emoji *Emoji `json:"emoji,omitempty"` + GIF *GIF `json:"gif,omitempty"` + File *File `json:"file,omitempty"` + Location *Location `json:"location,omitempty"` + System *System `json:"system,omitempty"` +} + +func NewText(body string) *Content { + return &Content{Type: TypeText, Text: &Text{Body: body}} +} + +func NewImage(img *Image) *Content { + return &Content{Type: TypeImage, Image: img} +} + +func NewVoice(v *Voice) *Content { + return &Content{Type: TypeVoice, Voice: v} +} + +func NewVideo(v *Video) *Content { + return &Content{Type: TypeVideo, Video: v} +} + +func NewEmoji(e *Emoji) *Content { + return &Content{Type: TypeEmoji, Emoji: e} +} + +func NewGIF(g *GIF) *Content { + return &Content{Type: TypeGIF, GIF: g} +} + +func NewFile(f *File) *Content { + return &Content{Type: TypeFile, File: f} +} + +func NewLocation(loc *Location) *Content { + return &Content{Type: TypeLocation, Location: loc} +} + +func NewSystem(s *System) *Content { + return &Content{Type: TypeSystem, System: s} +} + +// Marshal serializes content to JSON bytes for the BLOB column. It never returns a Go string. +func (c *Content) Marshal() ([]byte, error) { + if err := c.Validate(); err != nil { + return nil, err + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(c); err != nil { + return nil, fmt.Errorf("content: %w", err) + } + b := bytes.TrimSuffix(buf.Bytes(), []byte{'\n'}) + if len(b) > MaxContentBytes { + return nil, fmt.Errorf("content: exceeds %d bytes", MaxContentBytes) + } + return b, nil +} + +// UnmarshalContent decodes stored BLOB bytes. Rejects a plain UTF-8 string that is not JSON. +func UnmarshalContent(raw []byte) (*Content, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("content: empty") + } + if len(raw) > MaxContentBytes { + return nil, fmt.Errorf("content: exceeds %d bytes", MaxContentBytes) + } + var c Content + if err := json.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("content: %w", err) + } + if err := c.Validate(); err != nil { + return nil, err + } + return &c, nil +} + +func (c *Content) Validate() error { + if c == nil { + return fmt.Errorf("content: empty") + } + if !c.Type.Valid() { + return fmt.Errorf("content: unknown type %d", c.Type) + } + got, n := c.populated() + if n != 1 { + return fmt.Errorf("content: type %d wants exactly one payload, got %d", c.Type, n) + } + if got != c.Type { + return fmt.Errorf("content: type %d does not match payload %d", c.Type, got) + } + switch c.Type { + case TypeText: + return validateText(c.Text) + case TypeImage: + return validateMediaKey("image", c.Image.Key) + case TypeVoice: + return validateVoice(c.Voice) + case TypeVideo: + return validateMediaKey("video", c.Video.Key) + case TypeEmoji: + return validateEmoji(c.Emoji) + case TypeGIF: + return validateMediaKey("gif", c.GIF.Key) + case TypeFile: + return validateFile(c.File) + case TypeLocation: + return validateLocation(c.Location) + case TypeSystem: + return validateSystem(c.System) + default: + return fmt.Errorf("content: unknown type %d", c.Type) + } +} + +func (c *Content) populated() (Type, int) { + n := 0 + var t Type + if c.Text != nil { + n++ + t = TypeText + } + if c.Image != nil { + n++ + t = TypeImage + } + if c.Voice != nil { + n++ + t = TypeVoice + } + if c.Video != nil { + n++ + t = TypeVideo + } + if c.Emoji != nil { + n++ + t = TypeEmoji + } + if c.GIF != nil { + n++ + t = TypeGIF + } + if c.File != nil { + n++ + t = TypeFile + } + if c.Location != nil { + n++ + t = TypeLocation + } + if c.System != nil { + n++ + t = TypeSystem + } + return t, n +} + +func validateText(t *Text) error { + body := strings.TrimSpace(t.Body) + if body == "" { + return fmt.Errorf("content: text empty") + } + if !utf8.ValidString(t.Body) { + return fmt.Errorf("content: text not utf-8") + } + return nil +} + +func validateMediaKey(kind, key string) error { + if strings.TrimSpace(key) == "" { + return fmt.Errorf("content: %s key empty", kind) + } + return nil +} + +func validateVoice(v *Voice) error { + if err := validateMediaKey("voice", v.Key); err != nil { + return err + } + if v.DurationMS < 0 { + return fmt.Errorf("content: voice duration") + } + return nil +} + +func validateEmoji(e *Emoji) error { + if strings.TrimSpace(e.Pack) == "" || strings.TrimSpace(e.ID) == "" { + return fmt.Errorf("content: emoji pack/id empty") + } + return nil +} + +func validateFile(f *File) error { + if err := validateMediaKey("file", f.Key); err != nil { + return err + } + if strings.TrimSpace(f.Name) == "" { + return fmt.Errorf("content: file name empty") + } + if f.Size < 0 { + return fmt.Errorf("content: file size") + } + return nil +} + +func validateLocation(l *Location) error { + if l.Lat < -90 || l.Lat > 90 || l.Lng < -180 || l.Lng > 180 { + return fmt.Errorf("content: location range") + } + return nil +} + +func validateSystem(s *System) error { + if strings.TrimSpace(s.Kind) == "" { + return fmt.Errorf("content: system kind empty") + } + return nil +} + +// ObjectKeys returns media object keys in this payload (image/voice/video/gif/file). +func (c *Content) ObjectKeys() []string { + if c == nil { + return nil + } + var keys []string + add := func(k string) { + k = strings.TrimSpace(k) + if k != "" { + keys = append(keys, k) + } + } + switch { + case c.Image != nil: + add(c.Image.Key) + add(c.Image.ThumbKey) + case c.Voice != nil: + add(c.Voice.Key) + case c.Video != nil: + add(c.Video.Key) + add(c.Video.CoverKey) + case c.GIF != nil: + add(c.GIF.Key) + case c.File != nil: + add(c.File.Key) + } + return keys +} + +// ReplaceObjectKeys rewrites keys after CopyObject into perm/fav/. +func (c *Content) ReplaceObjectKeys(mapping map[string]string) { + if c == nil || len(mapping) == 0 { + return + } + repl := func(s *string) { + if s == nil { + return + } + if n, ok := mapping[*s]; ok { + *s = n + } + } + if c.Image != nil { + repl(&c.Image.Key) + repl(&c.Image.ThumbKey) + } + if c.Voice != nil { + repl(&c.Voice.Key) + } + if c.Video != nil { + repl(&c.Video.Key) + repl(&c.Video.CoverKey) + } + if c.GIF != nil { + repl(&c.GIF.Key) + } + if c.File != nil { + repl(&c.File.Key) + } +} diff --git a/internal/module/message/entity/content_test.go b/internal/module/message/entity/content_test.go new file mode 100644 index 0000000..b2909b7 --- /dev/null +++ b/internal/module/message/entity/content_test.go @@ -0,0 +1,99 @@ +package entity + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestTextMarshalIsJSONBytesNotRawString(t *testing.T) { + c := NewText("hello") + raw, err := c.Marshal() + if err != nil { + t.Fatal(err) + } + if bytes.Equal(raw, []byte("hello")) || !json.Valid(raw) { + t.Fatalf("want json bytes, got %q", raw) + } + if !bytes.Contains(raw, []byte(`"t":1`)) || !bytes.Contains(raw, []byte(`"body":"hello"`)) { + t.Fatalf("%s", raw) + } + got, err := UnmarshalContent(raw) + if err != nil { + t.Fatal(err) + } + if got.Type != TypeText || got.Text == nil || got.Text.Body != "hello" { + t.Fatalf("%+v", got) + } +} + +func TestMediaTypesRequireObjectKey(t *testing.T) { + cases := []*Content{ + NewImage(&Image{Key: "obj/a.jpg", Width: 10, Height: 10, Mime: "image/jpeg"}), + NewVoice(&Voice{Key: "obj/a.m4a", DurationMS: 1200}), + NewVideo(&Video{Key: "obj/a.mp4", CoverKey: "obj/c.jpg"}), + NewGIF(&GIF{Key: "obj/a.gif", Mime: "image/gif"}), + NewFile(&File{Key: "obj/a.pdf", Name: "a.pdf", Size: 9}), + NewEmoji(&Emoji{Pack: "spark", ID: "smile"}), + NewLocation(&Location{Lat: 31.2, Lng: 121.5, Name: "Shanghai"}), + NewSystem(&System{Kind: "member_join", Body: "joined"}), + } + for _, c := range cases { + raw, err := c.Marshal() + if err != nil { + t.Fatalf("type %d: %v", c.Type, err) + } + if !json.Valid(raw) { + t.Fatalf("type %d not json %q", c.Type, raw) + } + got, err := UnmarshalContent(raw) + if err != nil || got.Type != c.Type { + t.Fatalf("type %d roundtrip %v %+v", c.Type, err, got) + } + } +} + +func TestRejectsRawStringAndEmptyKey(t *testing.T) { + if _, err := UnmarshalContent([]byte("just a string")); err == nil { + t.Fatal("raw string") + } + if _, err := NewText(" ").Marshal(); err == nil { + t.Fatal("blank text") + } + if _, err := NewImage(&Image{}).Marshal(); err == nil || !strings.Contains(err.Error(), "key") { + t.Fatalf("image key: %v", err) + } + bad := &Content{Type: TypeText, Text: &Text{Body: "x"}, Image: &Image{Key: "k"}} + if _, err := bad.Marshal(); err == nil { + t.Fatal("two payloads") + } +} + +func TestMessagePayloadChecksColumnType(t *testing.T) { + raw, err := NewText("hi").Marshal() + if err != nil { + t.Fatal(err) + } + m := &Message{Type: TypeImage, Content: raw} + if _, err := m.Payload(); err == nil { + t.Fatal("mismatch") + } + m.Type = TypeText + c, err := m.Payload() + if err != nil || c.Text.Body != "hi" { + t.Fatalf("%v %+v", err, c) + } +} + +func TestObjectKeysAndReplace(t *testing.T) { + img := NewImage(&Image{Key: "chat/a.jpg", ThumbKey: "chat/t.jpg"}) + keys := img.ObjectKeys() + if len(keys) != 2 || keys[0] != "chat/a.jpg" { + t.Fatalf("%v", keys) + } + img.ReplaceObjectKeys(map[string]string{"chat/a.jpg": "perm/fav/1/x.jpg", "chat/t.jpg": "perm/fav/1/y.jpg"}) + if img.Image.Key != "perm/fav/1/x.jpg" || img.Image.ThumbKey != "perm/fav/1/y.jpg" { + t.Fatalf("%+v", img.Image) + } +} diff --git a/internal/module/message/entity/message.go b/internal/module/message/entity/message.go new file mode 100644 index 0000000..bcd52d3 --- /dev/null +++ b/internal/module/message/entity/message.go @@ -0,0 +1,29 @@ +package entity + +import "fmt" + +// Message is a persisted IM body. Content is serialized JSON bytes, not a string. +type Message struct { + ID int64 + ConversationID int64 + SenderID int64 + ClientMsgID string + Type Type + Content []byte + Status int + CreatedAtMS int64 +} + +func (m *Message) Payload() (*Content, error) { + if m == nil { + return nil, nil + } + c, err := UnmarshalContent(m.Content) + if err != nil { + return nil, err + } + if c.Type != m.Type { + return nil, fmt.Errorf("content: column type %d != payload %d", m.Type, c.Type) + } + return c, nil +} diff --git a/internal/module/message/entity/payload.go b/internal/module/message/entity/payload.go new file mode 100644 index 0000000..d987e27 --- /dev/null +++ b/internal/module/message/entity/payload.go @@ -0,0 +1,71 @@ +package entity + +// Text is a plain-text body. Stored as JSON bytes, never as a raw SQL string column. +type Text struct { + Body string `json:"body"` +} + +// Image references an uploaded object. Bytes themselves are not stored in messages.content. +type Image struct { + Key string `json:"key"` + Mime string `json:"mime,omitempty"` + Width int `json:"w,omitempty"` + Height int `json:"h,omitempty"` + Size int64 `json:"size,omitempty"` + ThumbKey string `json:"thumb_key,omitempty"` +} + +// Voice is an audio clip in object storage. +type Voice struct { + Key string `json:"key"` + DurationMS int `json:"duration_ms"` + Size int64 `json:"size,omitempty"` +} + +// Video is a video clip in object storage. +type Video struct { + Key string `json:"key"` + Mime string `json:"mime,omitempty"` + Width int `json:"w,omitempty"` + Height int `json:"h,omitempty"` + DurationMS int `json:"duration_ms,omitempty"` + Size int64 `json:"size,omitempty"` + CoverKey string `json:"cover_key,omitempty"` +} + +// Emoji is a sticker from a pack (not an image file in content). +type Emoji struct { + Pack string `json:"pack"` + ID string `json:"id"` +} + +// GIF is an animated image in object storage. +type GIF struct { + Key string `json:"key"` + Mime string `json:"mime,omitempty"` + Width int `json:"w,omitempty"` + Height int `json:"h,omitempty"` + Size int64 `json:"size,omitempty"` +} + +// File is a generic attachment in object storage. +type File struct { + Key string `json:"key"` + Name string `json:"name"` + Size int64 `json:"size"` + Mime string `json:"mime,omitempty"` +} + +// Location is a geographic pin. +type Location struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Name string `json:"name,omitempty"` + Address string `json:"address,omitempty"` +} + +// System is a server-generated notice (member join, recall hint, etc.). +type System struct { + Kind string `json:"kind"` + Body string `json:"body,omitempty"` +} diff --git a/internal/module/message/entity/type.go b/internal/module/message/entity/type.go new file mode 100644 index 0000000..51323f6 --- /dev/null +++ b/internal/module/message/entity/type.go @@ -0,0 +1,30 @@ +// Package entity holds message domain types. Content is serialized to bytes for MySQL. +package entity + +// Type is persisted as messages.message_type. Values are stable; do not reuse. +type Type int16 + +const ( + TypeText Type = 1 + TypeImage Type = 2 + TypeVoice Type = 3 + TypeVideo Type = 4 + TypeEmoji Type = 5 + TypeGIF Type = 6 + TypeFile Type = 7 + TypeLocation Type = 8 + TypeSystem Type = 9 +) + +func (t Type) Valid() bool { + return t >= TypeText && t <= TypeSystem +} + +const ( + StatusNormal = 1 + StatusRecalled = 2 + StatusHidden = 3 +) + +// MaxContentBytes is the serialized content size cap (matches TCP Frame max). +const MaxContentBytes = 65536 diff --git a/internal/module/message/logic/content.go b/internal/module/message/logic/content.go new file mode 100644 index 0000000..253ba46 --- /dev/null +++ b/internal/module/message/logic/content.go @@ -0,0 +1,19 @@ +package logic + +import ( + "fmt" + + "spark-server/internal/module/message/entity" +) + +// EncodeContent serializes a typed payload to BLOB bytes. Callers must store the bytes, not a string. +func EncodeContent(c *entity.Content) (entity.Type, []byte, error) { + if c == nil { + return 0, nil, fmt.Errorf("content: required") + } + raw, err := c.Marshal() + if err != nil { + return 0, nil, err + } + return c.Type, raw, nil +} diff --git a/internal/module/message/service.go b/internal/module/message/logic/send.go similarity index 56% rename from internal/module/message/service.go rename to internal/module/message/logic/send.go index 1595cfe..e2ea751 100644 --- a/internal/module/message/service.go +++ b/internal/module/message/logic/send.go @@ -1,23 +1,17 @@ -package message +// Package logic is the message send and history use-case layer. +package logic -import "context" +import ( + "context" -type Message struct { - ID int64 - ConversationID int64 - SenderID int64 - ClientMsgID string - Type int - Content []byte - Status int - CreatedAtMS int64 -} + "spark-server/internal/module/message/entity" +) type Repository interface { - Create(ctx context.Context, message *Message) error - GetByID(ctx context.Context, id int64) (*Message, error) - GetByClientMsgID(ctx context.Context, senderID, conversationID int64, clientMsgID string) (*Message, error) - ListByConversation(ctx context.Context, conversationID, lastMsgID int64, limit int) ([]*Message, error) + Create(ctx context.Context, message *entity.Message) error + GetByID(ctx context.Context, id int64) (*entity.Message, error) + GetByClientMsgID(ctx context.Context, senderID, conversationID int64, clientMsgID string) (*entity.Message, error) + ListByConversation(ctx context.Context, conversationID, lastMsgID int64, limit int) ([]*entity.Message, error) } type SendRequest struct { @@ -26,8 +20,7 @@ type SendRequest struct { DeviceID uint64 ClientMsgID string MsgID int64 - Type int - Content []byte + Content *entity.Content } type SendResponse struct { diff --git a/internal/module/message/model/memory.go b/internal/module/message/model/memory.go new file mode 100644 index 0000000..c831024 --- /dev/null +++ b/internal/module/message/model/memory.go @@ -0,0 +1,64 @@ +package model + +import ( + "context" + "sync" + + "spark-server/internal/module/message/entity" + "spark-server/internal/pkg/errs" +) + +// MemoryRepo is an in-process message store for tests and empty-DSN bring-up. +type MemoryRepo struct { + mu sync.Mutex + byID map[int64]*entity.Message +} + +func NewMemoryRepo() *MemoryRepo { + return &MemoryRepo{byID: make(map[int64]*entity.Message)} +} + +func (m *MemoryRepo) Create(_ context.Context, message *entity.Message) error { + if message == nil || message.ID <= 0 { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.byID[message.ID]; ok { + return errs.ErrDuplicate + } + cp := *message + if cp.Content != nil { + cp.Content = append([]byte(nil), message.Content...) + } + m.byID[message.ID] = &cp + return nil +} + +func (m *MemoryRepo) GetByID(_ context.Context, id int64) (*entity.Message, error) { + m.mu.Lock() + defer m.mu.Unlock() + row, ok := m.byID[id] + if !ok { + return nil, errs.ErrNotFound + } + cp := *row + if cp.Content != nil { + cp.Content = append([]byte(nil), row.Content...) + } + return &cp, nil +} + +func (m *MemoryRepo) All() []*entity.Message { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]*entity.Message, 0, len(m.byID)) + for _, row := range m.byID { + cp := *row + if cp.Content != nil { + cp.Content = append([]byte(nil), row.Content...) + } + out = append(out, &cp) + } + return out +} diff --git a/internal/module/message/model/message.go b/internal/module/message/model/message.go new file mode 100644 index 0000000..197d137 --- /dev/null +++ b/internal/module/message/model/message.go @@ -0,0 +1,53 @@ +// Package model is message persistence. One file per table. +package model + +import ( + "time" + + "spark-server/internal/module/message/entity" +) + +// Message maps table messages. content is a serialized BLOB, not a string. +type Message struct { + ID int64 `gorm:"column:id;primaryKey;comment:Snowflake MsgId,主键、排序键、同步游标"` + ConversationID int64 `gorm:"column:conversation_id;comment:所属会话 id"` + SenderID int64 `gorm:"column:sender_id;comment:发送者 user_id"` + ClientMsgID string `gorm:"column:client_msg_id;comment:客户端消息 id,与 sender+会话 组成幂等键"` + MessageType int16 `gorm:"column:message_type;comment:消息类型:1文本 2图片 3语音 4视频 5表情 6GIF 7文件 8位置 9系统"` + Content []byte `gorm:"column:content;type:blob;comment:序列化后的消息体(JSON 字节),禁止存原始 string;媒体只存对象存储 key"` + Status int `gorm:"column:status;comment:1=正常 2=已撤回 3=审核隐藏"` + CreatedAt time.Time `gorm:"column:created_at;comment:服务端入库时间"` +} + +func (Message) TableName() string { return "messages" } + +func (r *Message) Entity() *entity.Message { + if r == nil { + return nil + } + return &entity.Message{ + ID: r.ID, + ConversationID: r.ConversationID, + SenderID: r.SenderID, + ClientMsgID: r.ClientMsgID, + Type: entity.Type(r.MessageType), + Content: r.Content, + Status: r.Status, + CreatedAtMS: r.CreatedAt.UnixMilli(), + } +} + +func FromEntity(m *entity.Message) *Message { + if m == nil { + return nil + } + return &Message{ + ID: m.ID, + ConversationID: m.ConversationID, + SenderID: m.SenderID, + ClientMsgID: m.ClientMsgID, + MessageType: int16(m.Type), + Content: m.Content, + Status: m.Status, + } +} diff --git a/internal/module/message/model/repo.go b/internal/module/message/model/repo.go new file mode 100644 index 0000000..719d50a --- /dev/null +++ b/internal/module/message/model/repo.go @@ -0,0 +1,75 @@ +package model + +import ( + "context" + "errors" + "time" + + mysqldriver "github.com/go-sql-driver/mysql" + "gorm.io/gorm" + + "spark-server/internal/module/message/entity" + "spark-server/internal/pkg/errs" +) + +// Repo is MySQL persistence for messages. +type Repo struct { + gdb *gorm.DB +} + +func NewRepo(gdb *gorm.DB) *Repo { + return &Repo{gdb: gdb} +} + +func (m *Repo) session(ctx context.Context) (*gorm.DB, error) { + if m == nil || m.gdb == nil { + return nil, errs.ErrAvailable + } + return m.gdb.WithContext(ctx), nil +} + +func mapDBErr(err error) error { + if err == nil { + return nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return errs.ErrNotFound + } + var me *mysqldriver.MySQLError + if errors.As(err, &me) && me.Number == 1062 { + return errs.ErrDuplicate + } + return errs.ErrInner.WithError("message-mysql", err) +} + +func (m *Repo) GetByID(ctx context.Context, id int64) (*entity.Message, error) { + if id <= 0 { + return nil, errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row Message + if err := db.Where("id = ?", id).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.Entity(), nil +} + +func (m *Repo) Create(ctx context.Context, message *entity.Message) error { + if message == nil || message.ID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + row := FromEntity(message) + if message.CreatedAtMS > 0 { + row.CreatedAt = time.UnixMilli(message.CreatedAtMS) + } else { + row.CreatedAt = time.Now() + } + return mapDBErr(db.Create(row).Error) +} diff --git a/internal/module/session/policy.go b/internal/module/session/logic/policy.go similarity index 59% rename from internal/module/session/policy.go rename to internal/module/session/logic/policy.go index bcb573b..1fdca63 100644 --- a/internal/module/session/policy.go +++ b/internal/module/session/logic/policy.go @@ -1,4 +1,5 @@ -package sessionmod +// Package logic owns session kick policy. gnet does not decide which connection to drop. +package logic // Policy is owned by the session module, not gnet. // Same device_id kicks the old connection. Other devices of the user stay. diff --git a/internal/module/sync/service.go b/internal/module/sync/entity/request.go similarity index 53% rename from internal/module/sync/service.go rename to internal/module/sync/entity/request.go index fda3b00..5c7221c 100644 --- a/internal/module/sync/service.go +++ b/internal/module/sync/entity/request.go @@ -1,6 +1,5 @@ -package syncx - -import "context" +// Package entity holds sync request types. +package entity type InboxRequest struct { UserID int64 @@ -14,8 +13,3 @@ type MessagesRequest struct { LastMsgID int64 Limit int } - -type Service interface { - SyncInbox(ctx context.Context, req *InboxRequest) error - SyncMessages(ctx context.Context, req *MessagesRequest) error -} diff --git a/internal/module/sync/logic/service.go b/internal/module/sync/logic/service.go new file mode 100644 index 0000000..d29f2f2 --- /dev/null +++ b/internal/module/sync/logic/service.go @@ -0,0 +1,13 @@ +// Package logic is inbox/message sync use-cases. +package logic + +import ( + "context" + + "spark-server/internal/module/sync/entity" +) + +type Service interface { + SyncInbox(ctx context.Context, req *entity.InboxRequest) error + SyncMessages(ctx context.Context, req *entity.MessagesRequest) error +} diff --git a/internal/module/sync/model/device_sync_cursor.go b/internal/module/sync/model/device_sync_cursor.go new file mode 100644 index 0000000..c07da11 --- /dev/null +++ b/internal/module/sync/model/device_sync_cursor.go @@ -0,0 +1,14 @@ +package model + +import "time" + +// DeviceSyncCursor maps table device_sync_cursors. +type DeviceSyncCursor struct { + UserID int64 `gorm:"column:user_id;primaryKey;comment:用户 id"` + DeviceID int64 `gorm:"column:device_id;primaryKey;comment:设备 id"` + MailboxSeq int64 `gorm:"column:mailbox_seq;comment:该设备已同步到的 mailbox_events.seq"` + DialogScore int64 `gorm:"column:dialog_score;comment:会话列表 Redis ZSET 游标"` + UpdatedAt time.Time `gorm:"column:updated_at;comment:游标更新时间"` +} + +func (DeviceSyncCursor) TableName() string { return "device_sync_cursors" } diff --git a/internal/module/sync/model/mailbox_event.go b/internal/module/sync/model/mailbox_event.go new file mode 100644 index 0000000..9d0db18 --- /dev/null +++ b/internal/module/sync/model/mailbox_event.go @@ -0,0 +1,18 @@ +// Package model is mailbox and device-sync-cursor persistence. One file per table. +package model + +import "time" + +// MailboxEvent maps table mailbox_events. Successful online pushes do not write this table. +type MailboxEvent struct { + ID int64 `gorm:"column:id;primaryKey;comment:Snowflake 事件 id"` + UserID int64 `gorm:"column:user_id;comment:接收用户"` + DeviceID int64 `gorm:"column:device_id;comment:目标设备;0 表示该用户全部设备"` + Seq int64 `gorm:"column:seq;comment:该 user+device 上单调递增的邮箱序号"` + EventType int16 `gorm:"column:event_type;comment:事件类型:撤回/踢人/成员变更/好友/漏推消息等,不含成功在线推送的新消息"` + ConversationID *int64 `gorm:"column:conversation_id;comment:相关会话,可空"` + Payload []byte `gorm:"column:payload;comment:控制事件载荷(会话 id+消息 id 等),不含消息正文"` + CreatedAt time.Time `gorm:"column:created_at;comment:写入时间"` +} + +func (MailboxEvent) TableName() string { return "mailbox_events" } diff --git a/internal/module/upload/entity/credential.go b/internal/module/upload/entity/credential.go new file mode 100644 index 0000000..90ed80f --- /dev/null +++ b/internal/module/upload/entity/credential.go @@ -0,0 +1,17 @@ +// Package entity holds upload credential types. Message content stores the object key only. +package entity + +type Credential struct { + ObjectKey string `json:"object_key"` + UploadURL string `json:"upload_url"` + Method string `json:"method"` + Headers map[string]string `json:"headers"` + ExpiresIn int64 `json:"expires_in"` +} + +type Download struct { + ObjectKey string `json:"object_key"` + DownloadURL string `json:"download_url"` + Method string `json:"method"` + ExpiresIn int64 `json:"expires_in"` +} diff --git a/internal/module/upload/entity/purpose.go b/internal/module/upload/entity/purpose.go new file mode 100644 index 0000000..ae7a48b --- /dev/null +++ b/internal/module/upload/entity/purpose.go @@ -0,0 +1,146 @@ +package entity + +import ( + "fmt" + "path" + "strings" + "unicode" +) + +const ( + PurposeAvatar = "avatar" + PurposeGroupAvatar = "group_avatar" + PurposeChatImage = "chat_image" + PurposeChatVoice = "chat_voice" + PurposeChatVideo = "chat_video" + PurposeChatGIF = "chat_gif" + PurposeChatFile = "chat_file" + + PrefixPerm = "perm/" + PrefixChat = "chat/" + PrefixAvatar = "perm/avatar/" + PrefixGroup = "perm/group/" + PrefixFav = "perm/fav/" + + MethodPUT = "PUT" + MethodGET = "GET" +) + +const ( + maxAvatar = 2 << 20 + maxImage = 10 << 20 + maxVoice = 16 << 20 + maxVideo = 100 << 20 + maxFile = 50 << 20 + maxKeyLen = 512 + extDefault = "bin" +) + +// Spec is the server-owned policy for one upload purpose. +type Spec struct { + Purpose string + MaxSize int64 + MIMEs map[string]struct{} +} + +func LookupSpec(purpose string) (Spec, bool) { + s, ok := specs[purpose] + return s, ok +} + +func NormalizeMIME(raw string) string { + s := strings.ToLower(strings.TrimSpace(raw)) + s, _, _ = strings.Cut(s, ";") + return strings.TrimSpace(s) +} + +func ExtForMIME(mime string) string { + if ext, ok := mimeExt[NormalizeMIME(mime)]; ok { + return ext + } + return extDefault +} + +func ExtOfKey(key string) string { + base := path.Base(strings.TrimSpace(key)) + ext := strings.ToLower(strings.TrimPrefix(path.Ext(base), ".")) + if ext == "" || len(ext) > 8 { + return extDefault + } + for _, r := range ext { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + return extDefault + } + } + return ext +} + +func BuildObjectKey(purpose string, ownerID, objectID int64, day, ext string) string { + if ext == "" { + ext = extDefault + } + switch purpose { + case PurposeAvatar: + return fmt.Sprintf("%s%d/%s/%d.%s", PrefixAvatar, ownerID, day, objectID, ext) + case PurposeGroupAvatar: + return fmt.Sprintf("%s%d/%d.%s", PrefixGroup, ownerID, objectID, ext) + default: + return fmt.Sprintf("%s%s/%d/%d.%s", PrefixChat, day, ownerID, objectID, ext) + } +} + +func BuildFavKey(userID, objectID int64, day, ext string) string { + if ext == "" { + ext = extDefault + } + return fmt.Sprintf("%s%d/%s/%d.%s", PrefixFav, userID, day, objectID, ext) +} + +func ValidObjectKey(key string) bool { + if key == "" || len(key) > maxKeyLen { + return false + } + if strings.Contains(key, "..") || strings.ContainsAny(key, " \t\n\\") || strings.HasPrefix(key, "/") { + return false + } + return strings.HasPrefix(key, PrefixPerm) || strings.HasPrefix(key, PrefixChat) +} + +var specs = map[string]Spec{ + PurposeAvatar: {Purpose: PurposeAvatar, MaxSize: maxAvatar, MIMEs: imageMIMEs}, + PurposeGroupAvatar: {Purpose: PurposeGroupAvatar, MaxSize: maxAvatar, MIMEs: imageMIMEs}, + PurposeChatImage: {Purpose: PurposeChatImage, MaxSize: maxImage, MIMEs: imageMIMEs}, + PurposeChatGIF: {Purpose: PurposeChatGIF, MaxSize: maxImage, MIMEs: gifMIMEs}, + PurposeChatVoice: {Purpose: PurposeChatVoice, MaxSize: maxVoice, MIMEs: voiceMIMEs}, + PurposeChatVideo: {Purpose: PurposeChatVideo, MaxSize: maxVideo, MIMEs: videoMIMEs}, + PurposeChatFile: {Purpose: PurposeChatFile, MaxSize: maxFile, MIMEs: fileMIMEs}, +} + +var imageMIMEs = set("image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic", "image/heif") +var gifMIMEs = set("image/gif", "image/webp") +var voiceMIMEs = set("audio/mpeg", "audio/mp4", "audio/aac", "audio/amr", "audio/ogg", "audio/wav", "audio/webm", "audio/x-m4a") +var videoMIMEs = set("video/mp4", "video/quicktime", "video/webm") +var fileMIMEs = set( + "application/pdf", "application/zip", "application/x-zip-compressed", + "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/octet-stream", "text/plain", +) + +var mimeExt = map[string]string{ + "image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png", "image/webp": "webp", + "image/heic": "heic", "image/heif": "heif", "image/gif": "gif", + "audio/mpeg": "mp3", "audio/mp4": "m4a", "audio/aac": "aac", "audio/amr": "amr", + "audio/ogg": "ogg", "audio/wav": "wav", "audio/webm": "weba", "audio/x-m4a": "m4a", + "video/mp4": "mp4", "video/quicktime": "mov", "video/webm": "webm", + "application/pdf": "pdf", "application/zip": "zip", "application/x-zip-compressed": "zip", + "application/octet-stream": extDefault, "text/plain": "txt", +} + +func set(values ...string) map[string]struct{} { + m := make(map[string]struct{}, len(values)) + for _, v := range values { + m[v] = struct{}{} + } + return m +} diff --git a/internal/module/upload/entity/purpose_test.go b/internal/module/upload/entity/purpose_test.go new file mode 100644 index 0000000..71830a4 --- /dev/null +++ b/internal/module/upload/entity/purpose_test.go @@ -0,0 +1,55 @@ +package entity + +import ( + "strings" + "testing" +) + +func TestBuildObjectKeyByPurpose(t *testing.T) { + avatar := BuildObjectKey(PurposeAvatar, 11, 99, "20260917", "jpg") + if avatar != "perm/avatar/11/20260917/99.jpg" { + t.Fatalf("avatar %s", avatar) + } + chat := BuildObjectKey(PurposeChatImage, 11, 99, "20260917", "jpg") + if chat != "chat/20260917/11/99.jpg" { + t.Fatalf("chat %s", chat) + } + group := BuildObjectKey(PurposeGroupAvatar, 7, 99, "20260917", "png") + if group != "perm/group/7/99.png" { + t.Fatalf("group %s", group) + } + fav := BuildFavKey(11, 7, "20260917", "m4a") + if fav != "perm/fav/11/20260917/7.m4a" { + t.Fatalf("fav %s", fav) + } +} + +func TestValidObjectKey(t *testing.T) { + if !ValidObjectKey("chat/20260917/1/2.jpg") || !ValidObjectKey("perm/avatar/1/2.jpg") || !ValidObjectKey("perm/group/3/4.jpg") { + t.Fatal("want valid") + } + for _, key := range []string{"", "../x", "/chat/a", "other/a", "chat/a b.jpg", "chat/.. /x"} { + if ValidObjectKey(key) { + t.Fatalf("accepted %q", key) + } + } +} + +func TestNormalizeMIMEAndExt(t *testing.T) { + if got := NormalizeMIME("Image/JPEG; charset=utf-8"); got != "image/jpeg" { + t.Fatalf("mime %s", got) + } + if ExtForMIME("image/png") != "png" || ExtOfKey("chat/a/b.M4A") != "m4a" { + t.Fatal("ext") + } + spec, ok := LookupSpec(PurposeChatVoice) + if !ok || spec.MaxSize <= 0 { + t.Fatal("spec") + } + if _, ok := spec.MIMEs["audio/mpeg"]; !ok { + t.Fatal("voice mime") + } + if strings.Contains(BuildObjectKey(PurposeAvatar, 1, 1, "d", "jpg"), PrefixChat) { + t.Fatal("avatar must be perm") + } +} diff --git a/internal/module/upload/logic/credential.go b/internal/module/upload/logic/credential.go new file mode 100644 index 0000000..f1a4cea --- /dev/null +++ b/internal/module/upload/logic/credential.go @@ -0,0 +1,90 @@ +package logic + +import ( + "context" + + "spark-server/internal/module/upload/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" +) + +// CreateInput requests a single-object Presigned PUT. The client does not choose the key. +type CreateInput struct { + Purpose string `json:"purpose" validate:"required" label:"field.purpose" errmsg:"required:validate.upload.purpose_required"` + Mime string `json:"mime" validate:"required,max=128" label:"field.mime" errmsg:"required:validate.upload.mime_required;max:validate.upload.mime_invalid"` + Size int64 `json:"size" validate:"required,gt=0" label:"field.size" errmsg:"required:validate.upload.size_required;gt:validate.upload.size_invalid"` + GroupID int64 `json:"group_id" validate:"omitempty,gt=0" label:"field.group_id"` +} + +func (s *Service) CreateCredential(ctx context.Context, userID int64, in *CreateInput) (*entity.Credential, error) { + if !s.ready() { + return nil, errs.ErrAvailable + } + if userID <= 0 { + return nil, errs.ErrUnauthorized + } + if in == nil { + return nil, errs.ErrInvalidArgument + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return nil, err + } + if !s.allow(userID) { + return nil, errs.ErrRateLimited + } + _, mime, err := matchSpec(ctx, in) + if err != nil { + return nil, err + } + objectID, err := s.ids.NextID() + if err != nil { + return nil, err + } + ownerID := userID + if in.Purpose == entity.PurposeGroupAvatar { + if err := s.readyGroupAvatar(ctx, userID, in.GroupID); err != nil { + return nil, err + } + ownerID = in.GroupID + } + key := entity.BuildObjectKey(in.Purpose, ownerID, objectID, dayOf(s.now()), entity.ExtForMIME(mime)) + url, err := s.store.PresignPut(ctx, key, mime, s.ttl) + if err != nil { + return nil, err + } + return &entity.Credential{ + ObjectKey: key, + UploadURL: url, + Method: entity.MethodPUT, + Headers: map[string]string{"Content-Type": mime}, + ExpiresIn: s.expiresIn(), + }, nil +} + +func matchSpec(ctx context.Context, in *CreateInput) (entity.Spec, string, error) { + lang := reqctx.Lang(ctx) + spec, ok := entity.LookupSpec(in.Purpose) + if !ok { + return entity.Spec{}, "", errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUploadPurposeInvalid, nil)) + } + mime := entity.NormalizeMIME(in.Mime) + if _, ok := spec.MIMEs[mime]; !ok { + return entity.Spec{}, "", errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUploadMIMEInvalid, nil)) + } + if in.Size > spec.MaxSize { + return entity.Spec{}, "", errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUploadSizeExceeded, nil)) + } + return spec, mime, nil +} + +func (s *Service) readyGroupAvatar(ctx context.Context, userID, groupID int64) error { + if groupID <= 0 { + return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyUploadGroupRequired, nil)) + } + if s.groups == nil { + return nil + } + return s.groups.CanSetAvatar(ctx, userID, groupID) +} diff --git a/internal/module/upload/logic/credential_test.go b/internal/module/upload/logic/credential_test.go new file mode 100644 index 0000000..e09c11c --- /dev/null +++ b/internal/module/upload/logic/credential_test.go @@ -0,0 +1,97 @@ +package logic + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "spark-server/internal/infrastructure/id" + "spark-server/internal/module/upload/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" +) + +type fakeStore struct { + put, get string + putErr error +} + +func (f *fakeStore) PresignPut(_ context.Context, key, _ string, _ time.Duration) (string, error) { + if f.putErr != nil { + return "", f.putErr + } + if f.put != "" { + return f.put, nil + } + return "http://rustfs.test/" + key + "?sig=1", nil +} + +func (f *fakeStore) PresignGet(_ context.Context, key string, _ time.Duration) (string, error) { + if f.get != "" { + return f.get, nil + } + return "http://rustfs.test/" + key + "?get=1", nil +} + +func testUpload(t *testing.T) *Service { + t.Helper() + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + return NewService(&fakeStore{}, ids, Options{ + TTL: 5 * time.Minute, + Now: func() time.Time { return time.Date(2026, 9, 17, 8, 0, 0, 0, time.UTC) }, + }) +} + +func TestCreateCredentialChatVsAvatar(t *testing.T) { + svc := testUpload(t) + ctx := reqctx.With(t.Context(), &reqctx.RequestContext{UserID: 42, Language: "zh-CN"}) + chat, err := svc.CreateCredential(ctx, 42, &CreateInput{Purpose: entity.PurposeChatImage, Mime: "image/jpeg", Size: 1024}) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(chat.ObjectKey, "chat/20260917/42/") || chat.Method != "PUT" || chat.Headers["Content-Type"] != "image/jpeg" { + t.Fatalf("%+v", chat) + } + avatar, err := svc.CreateCredential(ctx, 42, &CreateInput{Purpose: entity.PurposeAvatar, Mime: "image/png", Size: 2048}) + if err != nil || !strings.HasPrefix(avatar.ObjectKey, "perm/avatar/42/20260917/") { + t.Fatalf("%+v %v", avatar, err) + } + group, err := svc.CreateCredential(ctx, 42, &CreateInput{Purpose: entity.PurposeGroupAvatar, Mime: "image/jpeg", Size: 1024, GroupID: 7}) + if err != nil || !strings.HasPrefix(group.ObjectKey, "perm/group/7/") { + t.Fatalf("%+v %v", group, err) + } + if _, err := svc.CreateCredential(ctx, 42, &CreateInput{Purpose: entity.PurposeGroupAvatar, Mime: "image/jpeg", Size: 1024}); err == nil { + t.Fatal("group avatar without group_id") + } +} + +func TestCreateCredentialRejectsBadMIMEAndSize(t *testing.T) { + svc := testUpload(t) + ctx := reqctx.With(t.Context(), &reqctx.RequestContext{UserID: 1, Language: "zh-CN"}) + if _, err := svc.CreateCredential(ctx, 1, &CreateInput{Purpose: entity.PurposeAvatar, Mime: "application/pdf", Size: 100}); err == nil { + t.Fatal("pdf avatar") + } + if _, err := svc.CreateCredential(ctx, 1, &CreateInput{Purpose: entity.PurposeChatImage, Mime: "image/jpeg", Size: 50 << 20}); err == nil { + t.Fatal("huge image") + } + if _, err := svc.CreateCredential(ctx, 0, &CreateInput{Purpose: entity.PurposeChatImage, Mime: "image/jpeg", Size: 1}); !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("uid %v", err) + } +} + +func TestCreateDownloadValidatesKey(t *testing.T) { + svc := testUpload(t) + ctx := reqctx.With(t.Context(), &reqctx.RequestContext{UserID: 1, Language: "en"}) + got, err := svc.CreateDownload(ctx, 1, &DownloadInput{ObjectKey: "chat/20260917/1/2.jpg"}) + if err != nil || got.Method != "GET" || !strings.Contains(got.DownloadURL, "chat/20260917/1/2.jpg") { + t.Fatalf("%+v %v", got, err) + } + if _, err := svc.CreateDownload(ctx, 1, &DownloadInput{ObjectKey: "../secret"}); err == nil { + t.Fatal("path escape") + } +} diff --git a/internal/module/upload/logic/download.go b/internal/module/upload/logic/download.go new file mode 100644 index 0000000..174d540 --- /dev/null +++ b/internal/module/upload/logic/download.go @@ -0,0 +1,48 @@ +package logic + +import ( + "context" + + "spark-server/internal/module/upload/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" +) + +// DownloadInput requests a short-lived GET URL for an already-uploaded object key. +type DownloadInput struct { + ObjectKey string `json:"object_key" validate:"required,max=512" label:"field.object_key" errmsg:"required:validate.upload.key_required;max:validate.upload.key_invalid"` +} + +func (s *Service) CreateDownload(ctx context.Context, userID int64, in *DownloadInput) (*entity.Download, error) { + if !s.ready() { + return nil, errs.ErrAvailable + } + if userID <= 0 { + return nil, errs.ErrUnauthorized + } + if in == nil { + return nil, errs.ErrInvalidArgument + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return nil, err + } + if !s.allow(userID) { + return nil, errs.ErrRateLimited + } + key := in.ObjectKey + if !entity.ValidObjectKey(key) { + return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyUploadKeyInvalid, nil)) + } + url, err := s.store.PresignGet(ctx, key, s.ttl) + if err != nil { + return nil, err + } + return &entity.Download{ + ObjectKey: key, + DownloadURL: url, + Method: entity.MethodGET, + ExpiresIn: s.expiresIn(), + }, nil +} diff --git a/internal/module/upload/logic/service.go b/internal/module/upload/logic/service.go new file mode 100644 index 0000000..9a6983a --- /dev/null +++ b/internal/module/upload/logic/service.go @@ -0,0 +1,72 @@ +package logic + +import ( + "context" + "strconv" + "time" + + "spark-server/internal/pkg/ratelimit" +) + +// Presigner issues short-lived object-storage URLs. Infrastructure implements it. +type Presigner interface { + PresignPut(ctx context.Context, key, contentType string, ttl time.Duration) (string, error) + PresignGet(ctx context.Context, key string, ttl time.Duration) (string, error) +} + +type IDGen interface { + NextID() (int64, error) +} + +// AvatarACL gates group_avatar uploads. Nil skips the check (tests). +type AvatarACL interface { + CanSetAvatar(ctx context.Context, userID, groupID int64) error +} + +type Options struct { + TTL time.Duration + Limit *ratelimit.Limiter + Now func() time.Time + Groups AvatarACL +} + +// Service issues upload/download credentials. HTTP and TCP share it. +type Service struct { + store Presigner + ids IDGen + ttl time.Duration + limit *ratelimit.Limiter + now func() time.Time + groups AvatarACL +} + +func NewService(store Presigner, ids IDGen, opt Options) *Service { + ttl := opt.TTL + if ttl <= 0 { + ttl = 15 * time.Minute + } + clock := opt.Now + if clock == nil { + clock = func() time.Time { return time.Now().UTC() } + } + return &Service{store: store, ids: ids, ttl: ttl, limit: opt.Limit, now: clock, groups: opt.Groups} +} + +func (s *Service) ready() bool { + return s != nil && s.store != nil && s.ids != nil +} + +func (s *Service) expiresIn() int64 { + return int64(s.ttl / time.Second) +} + +func (s *Service) allow(userID int64) bool { + if s.limit == nil { + return true + } + return s.limit.Allow("upload:" + strconv.FormatInt(userID, 10)) +} + +func dayOf(t time.Time) string { + return t.UTC().Format("20060102") +} diff --git a/internal/module/upload/service.go b/internal/module/upload/service.go deleted file mode 100644 index 9e10e21..0000000 --- a/internal/module/upload/service.go +++ /dev/null @@ -1,12 +0,0 @@ -package upload - -import "context" - -type Credential struct { - ObjectKey string - UploadURL string -} - -type Service interface { - CreateCredential(ctx context.Context, userID int64, mime string, size int64) (*Credential, error) -} diff --git a/internal/module/user/entity/avatar.go b/internal/module/user/entity/avatar.go new file mode 100644 index 0000000..c06cdd4 --- /dev/null +++ b/internal/module/user/entity/avatar.go @@ -0,0 +1,168 @@ +package entity + +import ( + "bytes" + "encoding/base64" + "fmt" + "hash/adler32" + "image" + "image/color" + "image/draw" + "image/png" + "math" + "strings" + "unicode" + "unicode/utf8" +) + +const ( + avatarSize = 256 + avatarSat = 0.3 + avatarVal = 0.9 + glyphW = 5 + glyphH = 7 + glyphPx = 20 +) + +// LetterAvatarPNG draws a first-letter avatar. Hue follows FastAdmin letter_avatar (adler32 % 360). +func LetterAvatarPNG(text string) ([]byte, error) { + img := image.NewRGBA(image.Rect(0, 0, avatarSize, avatarSize)) + bg := AvatarColor(text) + draw.Draw(img, img.Bounds(), &image.Uniform{C: bg}, image.Point{}, draw.Src) + if letter := AvatarLetter(text); letter != "" { + if rows, ok := avatarGlyphs[letter[0]]; ok { + blitGlyph(img, rows, color.White) + } else { + fillPerson(img, color.White) + } + } else { + fillPerson(img, color.White) + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +const avatarDataURIPrefix = "data:image/svg+xml;base64," + +// LetterAvatarDataURI is a compact SVG fallback (FastAdmin letter_avatar). Always fits users.avatar VARCHAR(512). +func LetterAvatarDataURI(text string) string { + c := AvatarColor(text) + fill := fmt.Sprintf("rgb(%d,%d,%d)", c.R, c.G, c.B) + var svg string + if letter := AvatarLetter(text); letter != "" { + svg = fmt.Sprintf(`%s`, fill, letter) + } else { + svg = fmt.Sprintf(``, fill) + } + return avatarDataURIPrefix + base64.StdEncoding.EncodeToString([]byte(svg)) +} + +// AvatarLetter is the uppercase first rune. Non-Latin glyphs use the silhouette fallback. +func AvatarLetter(text string) string { + text = strings.TrimSpace(text) + if text == "" { + return "?" + } + r, _ := utf8.DecodeRuneInString(text) + if r == utf8.RuneError { + return "?" + } + r = unicode.ToUpper(r) + if r > unicode.MaxASCII || !unicode.IsLetter(r) && !unicode.IsDigit(r) { + return "" + } + return string(r) +} + +// AvatarColor is the pastel background from adler32(text) % 360, sat 0.3 / val 0.9. +func AvatarColor(text string) color.RGBA { + hue := float64(adler32.Checksum([]byte(text))%360) / 360 + return hsvRGB(hue, avatarSat, avatarVal) +} + +func blitGlyph(img *image.RGBA, rows [glyphH]byte, c color.Color) { + gw, gh := glyphW*glyphPx, glyphH*glyphPx + ox := (avatarSize - gw) / 2 + oy := (avatarSize - gh) / 2 + for y := 0; y < glyphH; y++ { + row := rows[y] + for x := 0; x < glyphW; x++ { + if row&(1< 512 { + t.Fatalf("%q len %d", name, len(got)) + } + } + a, b := LetterAvatarDataURI("test01"), LetterAvatarDataURI("ada") + if a == b { + t.Fatal("same uri") + } +} diff --git a/internal/module/user/entity/device.go b/internal/module/user/entity/device.go new file mode 100644 index 0000000..3c83794 --- /dev/null +++ b/internal/module/user/entity/device.go @@ -0,0 +1,68 @@ +package entity + +import ( + "strings" + "time" +) + +const ( + DeviceIOS = "ios" + DeviceAndroid = "android" + DeviceHarmony = "harmony" + DeviceWindows = "windows" + DeviceMacOS = "macos" + DeviceWeb = "web" +) + +var deviceTypes = map[string]struct{}{ + DeviceIOS: {}, + DeviceAndroid: {}, + DeviceHarmony: {}, + DeviceWindows: {}, + DeviceMacOS: {}, + DeviceWeb: {}, +} + +// Device is a login device row for one user. +type Device struct { + ID int64 // table AUTO_INCREMENT; business key is (UserID, DeviceID) + UserID int64 + DeviceID uint64 + DeviceType string + DeviceName string + AppVersion string + Language string + LastIP string + LastLoginAt time.Time + CreatedAt time.Time +} + +// LoginDevice is the device snapshot supplied at login (not a table row). +type LoginDevice struct { + DeviceID uint64 + DeviceType string + Platform string // alias of DeviceType; HTTP still accepts `platform` + DeviceName string + AppVersion string + Language string + RealIP string +} + +func NormalizeDeviceType(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} + +func ValidDeviceType(s string) bool { + _, ok := deviceTypes[NormalizeDeviceType(s)] + return ok +} + +func DeviceTypeOf(d *LoginDevice) string { + if d == nil { + return "" + } + if t := NormalizeDeviceType(d.DeviceType); ValidDeviceType(t) { + return t + } + return NormalizeDeviceType(d.Platform) +} diff --git a/internal/module/user/entity/email.go b/internal/module/user/entity/email.go new file mode 100644 index 0000000..23949e1 --- /dev/null +++ b/internal/module/user/entity/email.go @@ -0,0 +1,24 @@ +package entity + +import ( + "regexp" + "strings" + "time" +) + +var testEmailRe = regexp.MustCompile(`^test(0[1-9]|10)@spark\.com$`) + +// EmailBinding maps one normalized email to one user. +type EmailBinding struct { + Email string + UserID int64 + CreatedAt time.Time +} + +func NormalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +func IsTestEmail(email string) bool { + return testEmailRe.MatchString(NormalizeEmail(email)) +} diff --git a/internal/module/user/entity/email_test.go b/internal/module/user/entity/email_test.go new file mode 100644 index 0000000..a4a3a23 --- /dev/null +++ b/internal/module/user/entity/email_test.go @@ -0,0 +1,18 @@ +package entity + +import "testing" + +func TestIsTestEmail(t *testing.T) { + ok := []string{"test01@spark.com", "test10@spark.com", "TEST05@SPARK.COM"} + for _, e := range ok { + if !IsTestEmail(e) { + t.Fatalf("want test %s", e) + } + } + bad := []string{"test00@spark.com", "test11@spark.com", "test1@spark.com", "other@spark.com", "test01@example.com"} + for _, e := range bad { + if IsTestEmail(e) { + t.Fatalf("not test %s", e) + } + } +} diff --git a/internal/module/user/entity/login_log.go b/internal/module/user/entity/login_log.go new file mode 100644 index 0000000..897172f --- /dev/null +++ b/internal/module/user/entity/login_log.go @@ -0,0 +1,16 @@ +package entity + +import "time" + +// LoginLog is one successful login event (append-only). +type LoginLog struct { + ID int64 + UserID int64 + DeviceID uint64 + DeviceType string + DeviceName string + AppVersion string + Language string + IP string + CreatedAt time.Time +} diff --git a/internal/module/user/entity/qrcode.go b/internal/module/user/entity/qrcode.go new file mode 100644 index 0000000..e23a137 --- /dev/null +++ b/internal/module/user/entity/qrcode.go @@ -0,0 +1,31 @@ +package entity + +import ( + "strings" + + "github.com/teris-io/shortid" +) + +const ( + qrTokenMin = 9 + qrTokenMax = 20 +) + +// NewQRCode is an opaque scan ticket from shortid. It is not uid, spark_id, or a Redis key. +func NewQRCode() (string, error) { + return shortid.Generate() +} + +func ValidQRCode(s string) bool { + s = strings.TrimSpace(s) + n := len(s) + if n < qrTokenMin || n > qrTokenMax { + return false + } + for i := 0; i < n; i++ { + if !strings.ContainsRune(shortid.DefaultABC, rune(s[i])) { + return false + } + } + return true +} diff --git a/internal/module/user/entity/qrcode_test.go b/internal/module/user/entity/qrcode_test.go new file mode 100644 index 0000000..63a2b74 --- /dev/null +++ b/internal/module/user/entity/qrcode_test.go @@ -0,0 +1,23 @@ +package entity + +import "testing" + +func TestNewQRCodeOpaque(t *testing.T) { + got, err := NewQRCode() + if err != nil { + t.Fatal(err) + } + if !ValidQRCode(got) { + t.Fatalf("%s", got) + } + other, err := NewQRCode() + if err != nil { + t.Fatal(err) + } + if got == other { + t.Fatal("not random") + } + if ValidQRCode("spark:u:1") || ValidQRCode("spark:q:zz") || ValidQRCode("") { + t.Fatal("invalid accepted") + } +} diff --git a/internal/module/user/entity/spark_id.go b/internal/module/user/entity/spark_id.go new file mode 100644 index 0000000..6072a69 --- /dev/null +++ b/internal/module/user/entity/spark_id.go @@ -0,0 +1,48 @@ +package entity + +import ( + "regexp" + "strings" +) + +const ( + SparkIDMin = 6 + SparkIDMax = 32 +) + +var sparkIDRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]{5,31}$`) + +func ValidSparkID(id string) bool { + n := len(id) + return n >= SparkIDMin && n <= SparkIDMax && sparkIDRe.MatchString(id) +} + +// DefaultSparkID is a unique, letter-prefixed handle derived from the Snowflake id. +func DefaultSparkID(userID int64) string { + s := "s" + strings.ToLower(strconv36(userID)) + for len(s) < SparkIDMin { + s += "0" + } + if len(s) > SparkIDMax { + s = s[:SparkIDMax] + } + return s +} + +func strconv36(n int64) string { + if n < 0 { + n = -n + } + const digits = "0123456789abcdefghijklmnopqrstuvwxyz" + if n == 0 { + return "0" + } + var buf [16]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = digits[n%36] + n /= 36 + } + return string(buf[i:]) +} diff --git a/internal/module/user/entity/spark_id_test.go b/internal/module/user/entity/spark_id_test.go new file mode 100644 index 0000000..597a810 --- /dev/null +++ b/internal/module/user/entity/spark_id_test.go @@ -0,0 +1,14 @@ +package entity + +import "testing" + +func TestDefaultSparkID(t *testing.T) { + a := DefaultSparkID(100) + b := DefaultSparkID(101) + if !ValidSparkID(a) || !ValidSparkID(b) || a == b || a[0] != 's' { + t.Fatalf("%s %s", a, b) + } + if !ValidSparkID("alice_01") || ValidSparkID("1abcde") || ValidSparkID("ab") { + t.Fatal("spark_id regex") + } +} diff --git a/internal/module/user/entity/user.go b/internal/module/user/entity/user.go new file mode 100644 index 0000000..e719955 --- /dev/null +++ b/internal/module/user/entity/user.go @@ -0,0 +1,32 @@ +// Package entity holds user domain types. Persistence belongs in model. +package entity + +import "time" + +const StatusActive = 1 + +const ( + GenderUnknown = 0 + GenderMale = 1 + GenderFemale = 2 +) + +func ValidGender(g int) bool { + return g == GenderUnknown || g == GenderMale || g == GenderFemale +} + +// User is the account. ID is uid (Snowflake), never AUTO_INCREMENT. +type User struct { + ID int64 + SparkID string + Nickname string + Avatar string + QRCode string + Birthday time.Time + Gender int + Status int + LastLoginAt time.Time + LastActiveAt time.Time + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/internal/module/user/logic/avatar.go b/internal/module/user/logic/avatar.go new file mode 100644 index 0000000..ee02231 --- /dev/null +++ b/internal/module/user/logic/avatar.go @@ -0,0 +1,61 @@ +package logic + +import ( + "context" + "strings" + "time" + + uploadentity "spark-server/internal/module/upload/entity" + "spark-server/internal/module/user/entity" +) + +const defaultAvatarMIME = "image/png" + +func (s *Service) ensureDefaultAvatar(ctx context.Context, u *entity.User) { + if u == nil || strings.TrimSpace(u.Avatar) != "" { + return + } + s.stampDefaultAvatar(ctx, u) +} + +func (s *Service) stampDefaultAvatar(ctx context.Context, u *entity.User) { + if u == nil || u.ID <= 0 { + return + } + fallback := entity.LetterAvatarDataURI(u.Nickname) + avatar := fallback + if key := s.putLetterPNG(ctx, u); key != "" { + avatar = key + } + prev := u.Avatar + u.Avatar = avatar + if s.repo == nil || prev == avatar { + return + } + if err := s.repo.UpdateProfile(ctx, u); err == nil { + return + } + u.Avatar = fallback + if prev != fallback { + _ = s.repo.UpdateProfile(ctx, u) + } +} + +func (s *Service) putLetterPNG(ctx context.Context, u *entity.User) string { + if s == nil || s.objects == nil || s.ids == nil || u == nil || u.ID <= 0 { + return "" + } + raw, err := entity.LetterAvatarPNG(u.Nickname) + if err != nil || len(raw) == 0 { + return "" + } + objectID, err := s.ids.NextID() + if err != nil { + return "" + } + key := uploadentity.BuildObjectKey(uploadentity.PurposeAvatar, u.ID, objectID, time.Now().Format("20060102"), "png") + if err := s.objects.Put(ctx, key, raw, defaultAvatarMIME); err != nil { + return "" + } + return key +} diff --git a/internal/module/user/logic/email_code.go b/internal/module/user/logic/email_code.go new file mode 100644 index 0000000..c211da6 --- /dev/null +++ b/internal/module/user/logic/email_code.go @@ -0,0 +1,67 @@ +package logic + +import ( + "context" + "crypto/rand" + "encoding/binary" + "fmt" + + "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" +) + +const ( + codeTTLSeconds = 300 + codeCooldownSeconds = 60 +) + +// EmailCodeTTLSeconds is the OTP lifetime returned to clients. +const EmailCodeTTLSeconds = codeTTLSeconds + +// CodeStore is the email OTP cache (Redis). +type CodeStore interface { + Put(ctx context.Context, email, code string, ttlSeconds int) error + Get(ctx context.Context, email string) (string, error) + Del(ctx context.Context, email string) error + SetCooldown(ctx context.Context, email string, ttlSeconds int) (bool, error) +} + +func (s *Service) SendEmailCode(ctx context.Context, email string) error { + if s == nil || s.codes == nil { + return errs.ErrAvailable + } + email = entity.NormalizeEmail(email) + if email == "" { + return errs.ErrInvalidArgument + } + if entity.IsTestEmail(email) { + return nil + } + ok, err := s.codes.SetCooldown(ctx, email, codeCooldownSeconds) + if err != nil { + return err + } + if !ok { + return errs.ErrRateLimited + } + code, err := randomDigits(6) + if err != nil { + return errs.ErrInner.WithError("email-code", err) + } + return s.codes.Put(ctx, email, code, codeTTLSeconds) +} + +func randomDigits(n int) (string, error) { + if n <= 0 { + return "", fmt.Errorf("digits") + } + var buf [8]byte + out := make([]byte, n) + for i := 0; i < n; i++ { + if _, err := rand.Read(buf[:4]); err != nil { + return "", err + } + out[i] = '0' + byte(binary.BigEndian.Uint32(buf[:4])%10) + } + return string(out), nil +} diff --git a/internal/module/user/logic/email_code_test.go b/internal/module/user/logic/email_code_test.go new file mode 100644 index 0000000..0886fdb --- /dev/null +++ b/internal/module/user/logic/email_code_test.go @@ -0,0 +1,21 @@ +package logic + +import ( + "errors" + "testing" + + "spark-server/internal/pkg/errs" +) + +func TestSendEmailCodeCooldown(t *testing.T) { + svc, _, _ := testService(t) + if err := svc.SendEmailCode(t.Context(), "test06@spark.com"); err != nil { + t.Fatal(err) + } + if err := svc.SendEmailCode(t.Context(), "ada@example.com"); err != nil { + t.Fatal(err) + } + if err := svc.SendEmailCode(t.Context(), "ada@example.com"); !errors.Is(err, errs.ErrRateLimited) { + t.Fatalf("cooldown %v", err) + } +} diff --git a/internal/module/user/logic/find.go b/internal/module/user/logic/find.go new file mode 100644 index 0000000..9d0580b --- /dev/null +++ b/internal/module/user/logic/find.go @@ -0,0 +1,31 @@ +package logic + +import ( + "context" + "strings" + + "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" +) + +func (s *Service) GetByEmail(ctx context.Context, email string) (*entity.User, error) { + if s == nil || s.repo == nil { + return nil, errs.ErrNotFound + } + email = entity.NormalizeEmail(email) + if email == "" { + return nil, errs.ErrNotFound + } + return s.repo.GetByEmail(ctx, email) +} + +func (s *Service) GetBySparkID(ctx context.Context, sparkID string) (*entity.User, error) { + if s == nil || s.repo == nil { + return nil, errs.ErrNotFound + } + sparkID = strings.TrimSpace(sparkID) + if sparkID == "" { + return nil, errs.ErrNotFound + } + return s.repo.GetBySparkID(ctx, sparkID) +} diff --git a/internal/module/user/logic/login.go b/internal/module/user/logic/login.go new file mode 100644 index 0000000..e512986 --- /dev/null +++ b/internal/module/user/logic/login.go @@ -0,0 +1,191 @@ +package logic + +import ( + "context" + "crypto/subtle" + "errors" + "strings" + "time" + + "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" +) + +// AuthenticateEmail verifies OTP (test01–test10@spark.com accept any code), +// creates the user on first login, and upserts the login device. +func (s *Service) AuthenticateEmail(ctx context.Context, email, verifyCode string, device *entity.LoginDevice) (*entity.User, error) { + if s == nil || s.repo == nil || s.ids == nil { + return nil, errs.ErrUnauthorized + } + email = entity.NormalizeEmail(email) + if email == "" { + return nil, errs.ErrUnauthorized + } + if err := s.checkVerifyCode(ctx, email, verifyCode); err != nil { + return nil, err + } + u, err := s.getOrCreate(ctx, email) + if err != nil { + return nil, err + } + if err := s.ensureQRCode(ctx, u); err != nil { + return nil, err + } + s.ensureDefaultAvatar(ctx, u) + if u.Status != entity.StatusActive { + return nil, errs.ErrForbidden + } + if err := s.touchDevice(ctx, u.ID, device); err != nil { + return nil, err + } + if err := s.recordLogin(ctx, u.ID, device); err != nil { + return nil, err + } + now := time.Now() + if err := s.repo.TouchLogin(ctx, u.ID, now); err != nil { + return nil, err + } + u.LastLoginAt = now + u.LastActiveAt = now + return u, nil +} + +func (s *Service) checkVerifyCode(ctx context.Context, email, verifyCode string) error { + if entity.IsTestEmail(email) { + return nil + } + want, err := s.codes.Get(ctx, email) + if err != nil { + return err + } + if want == "" || subtle.ConstantTimeCompare([]byte(want), []byte(verifyCode)) != 1 { + return errs.ErrUnauthorized + } + _ = s.codes.Del(ctx, email) + return nil +} + +func (s *Service) getOrCreate(ctx context.Context, email string) (*entity.User, error) { + u, err := s.repo.GetByEmail(ctx, email) + if err == nil { + return u, nil + } + if !errors.Is(err, errs.ErrNotFound) { + return nil, err + } + return s.createWithEmail(ctx, email) +} + +func (s *Service) createWithEmail(ctx context.Context, email string) (*entity.User, error) { + id, err := s.ids.NextID() + if err != nil { + return nil, err + } + now := time.Now() + qr, err := entity.NewQRCode() + if err != nil { + return nil, err + } + nick := nicknameFromEmail(email) + u := &entity.User{ + ID: id, + SparkID: entity.DefaultSparkID(id), + Nickname: nick, + Avatar: entity.LetterAvatarDataURI(nick), + Gender: entity.GenderUnknown, + Status: entity.StatusActive, + QRCode: qr, + LastLoginAt: now, + LastActiveAt: now, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.repo.CreateWithEmail(ctx, u, email); err != nil { + if errors.Is(err, errs.ErrDuplicate) { + return s.repo.GetByEmail(ctx, email) + } + return nil, err + } + s.stampDefaultAvatar(ctx, u) + return u, nil +} + +func (s *Service) ensureQRCode(ctx context.Context, u *entity.User) error { + if u == nil || entity.ValidQRCode(u.QRCode) { + return nil + } + qr, err := entity.NewQRCode() + if err != nil { + return err + } + u.QRCode = qr + return s.repo.UpdateQRCode(ctx, u.ID, u.QRCode) +} + +func (s *Service) touchDevice(ctx context.Context, userID int64, device *entity.LoginDevice) error { + if device == nil || device.DeviceID == 0 { + return errs.ErrInvalidArgument + } + typ := entity.DeviceTypeOf(device) + if !entity.ValidDeviceType(typ) { + return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginDeviceTypeRequired, nil)) + } + now := time.Now() + ip := strings.TrimSpace(device.RealIP) + if ip == "" { + ip = reqctx.RealIP(ctx) + } + lang := strings.TrimSpace(device.Language) + if lang == "" { + lang = reqctx.Language(ctx) + } + return s.repo.UpsertDevice(ctx, &entity.Device{ + UserID: userID, + DeviceID: device.DeviceID, + DeviceType: typ, + DeviceName: device.DeviceName, + AppVersion: device.AppVersion, + Language: lang, + LastIP: ip, + LastLoginAt: now, + CreatedAt: now, + }) +} + +func (s *Service) recordLogin(ctx context.Context, userID int64, device *entity.LoginDevice) error { + if device == nil { + return errs.ErrInvalidArgument + } + ip := strings.TrimSpace(device.RealIP) + if ip == "" { + ip = reqctx.RealIP(ctx) + } + lang := strings.TrimSpace(device.Language) + if lang == "" { + lang = reqctx.Language(ctx) + } + return s.repo.InsertLoginLog(ctx, &entity.LoginLog{ + UserID: userID, + DeviceID: device.DeviceID, + DeviceType: entity.DeviceTypeOf(device), + DeviceName: device.DeviceName, + AppVersion: device.AppVersion, + Language: lang, + IP: ip, + CreatedAt: time.Now(), + }) +} + +func nicknameFromEmail(email string) string { + local, _, _ := strings.Cut(email, "@") + local = strings.TrimSpace(local) + if local == "" { + return "user" + } + if len(local) > 64 { + return local[:64] + } + return local +} diff --git a/internal/module/user/logic/login_test.go b/internal/module/user/logic/login_test.go new file mode 100644 index 0000000..49bedd1 --- /dev/null +++ b/internal/module/user/logic/login_test.go @@ -0,0 +1,198 @@ +package logic + +import ( + "bytes" + "context" + "errors" + "image/png" + "strconv" + "strings" + "testing" + + "spark-server/internal/infrastructure/id" + "spark-server/internal/module/user/entity" + "spark-server/internal/module/user/model" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" +) + +func TestAuthenticateTestEmailAnyCode(t *testing.T) { + svc, repo, _ := testService(t) + ctx := reqctx.With(t.Context(), &reqctx.RequestContext{RealIP: "203.0.113.10", Language: "zh-CN", AppVersion: "1.0.0"}) + device := &entity.LoginDevice{DeviceID: 7, Platform: "ios", DeviceName: "iPhone"} + u1, err := svc.AuthenticateEmail(ctx, "test01@spark.com", "whatever", device) + if err != nil { + t.Fatal(err) + } + if u1.ID <= 0 || !entity.ValidSparkID(u1.SparkID) || u1.Nickname != "test01" || !entity.ValidQRCode(u1.QRCode) { + t.Fatalf("%+v", u1) + } + prefix := "perm/avatar/" + strconv.FormatInt(u1.ID, 10) + "/" + if !strings.HasPrefix(u1.Avatar, prefix) || !strings.HasSuffix(u1.Avatar, ".png") { + t.Fatalf("default avatar %s", u1.Avatar) + } + store, ok := svc.objects.(*memObjects) + if !ok { + t.Fatal("mem objects") + } + if _, err := png.Decode(bytes.NewReader(store.kv[u1.Avatar])); err != nil { + t.Fatalf("avatar png %v", err) + } + if strings.Contains(u1.QRCode, strconv.FormatInt(u1.ID, 10)) { + t.Fatalf("qr leaked uid %s", u1.QRCode) + } + got, err := svc.GetByQRCode(ctx, u1.QRCode) + if err != nil || got.ID != u1.ID { + t.Fatalf("lookup qr %v %+v", err, got) + } + if u1.LastLoginAt.IsZero() || u1.LastActiveAt.IsZero() || u1.Gender != entity.GenderUnknown { + t.Fatalf("profile times/gender %+v", u1) + } + u2, err := svc.AuthenticateEmail(ctx, "TEST01@spark.com", "0000", device) + if err != nil { + t.Fatal(err) + } + if u2.ID != u1.ID { + t.Fatalf("same user %d %d", u1.ID, u2.ID) + } + dev, err := repo.GetDevice(ctx, u1.ID, 7) + if err != nil { + t.Fatal(err) + } + if dev.DeviceType != "ios" || dev.LastIP != "203.0.113.10" || dev.AppVersion != "" { + t.Fatalf("%+v", dev) + } + device.AppVersion = "1.0.0" + device.RealIP = "203.0.113.10" + device.Language = "zh-CN" + if _, err := svc.AuthenticateEmail(ctx, "test01@spark.com", "x", device); err != nil { + t.Fatal(err) + } + dev, err = repo.GetDevice(ctx, u1.ID, 7) + if err != nil { + t.Fatal(err) + } + if dev.AppVersion != "1.0.0" || dev.Language != "zh-CN" { + t.Fatalf("%+v", dev) + } + logs := repo.LoginLogs() + if len(logs) < 2 || logs[0].DeviceType != entity.DeviceIOS || logs[0].DeviceID != 7 { + t.Fatalf("logs %+v", logs) + } +} + +func TestStaleUIDQRCodeIsReplaced(t *testing.T) { + svc, repo, _ := testService(t) + ctx := t.Context() + device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS} + u, err := svc.AuthenticateEmail(ctx, "test08@spark.com", "1", device) + if err != nil { + t.Fatal(err) + } + if err := repo.UpdateQRCode(ctx, u.ID, "spark:u:"+strconv.FormatInt(u.ID, 10)); err != nil { + t.Fatal(err) + } + u2, err := svc.AuthenticateEmail(ctx, "test08@spark.com", "1", device) + if err != nil { + t.Fatal(err) + } + if !entity.ValidQRCode(u2.QRCode) || strings.Contains(u2.QRCode, "spark:") { + t.Fatalf("stale qr %s", u2.QRCode) + } +} + +func TestAuthenticateNonTestNeedsCode(t *testing.T) { + svc, _, codes := testService(t) + ctx := t.Context() + device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceWeb} + _, err := svc.AuthenticateEmail(ctx, "ada@example.com", "123456", device) + if !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("no code: %v", err) + } + if err := codes.Put(ctx, "ada@example.com", "123456", 60); err != nil { + t.Fatal(err) + } + u, err := svc.AuthenticateEmail(ctx, "ada@example.com", "123456", device) + if err != nil { + t.Fatal(err) + } + if u.ID <= 0 { + t.Fatal(u) + } + _, err = svc.AuthenticateEmail(ctx, "ada@example.com", "123456", device) + if !errors.Is(err, errs.ErrUnauthorized) { + t.Fatalf("consumed: %v", err) + } +} + +func TestSnowflakeIDsIncrease(t *testing.T) { + svc, _, _ := testService(t) + ctx := t.Context() + a, err := svc.AuthenticateEmail(ctx, "test02@spark.com", "1", &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS}) + if err != nil { + t.Fatal(err) + } + b, err := svc.AuthenticateEmail(ctx, "test03@spark.com", "1", &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS}) + if err != nil { + t.Fatal(err) + } + if b.ID <= a.ID { + t.Fatalf("ids %d %d", a.ID, b.ID) + } +} + +func TestRegisterFallsBackToDataURIWithoutObjectStore(t *testing.T) { + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + svc := NewService(model.NewMemoryRepo(), model.NewMemoryCodes(), ids, nil) + device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS} + u, err := svc.AuthenticateEmail(t.Context(), "test09@spark.com", "1", device) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(u.Avatar, "data:image/svg+xml;base64,") { + t.Fatalf("fallback avatar %s", u.Avatar) + } +} + +func TestRegisterFallsBackWhenObjectPutFails(t *testing.T) { + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + svc := NewService(model.NewMemoryRepo(), model.NewMemoryCodes(), ids, failObjects{}) + device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS} + u, err := svc.AuthenticateEmail(t.Context(), "test09@spark.com", "1", device) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(u.Avatar, "data:image/svg+xml;base64,") { + t.Fatalf("fallback avatar %s", u.Avatar) + } +} + +func TestEmptyAvatarBackfilledOnLogin(t *testing.T) { + svc, _, _ := testService(t) + ctx := t.Context() + device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS} + u, err := svc.AuthenticateEmail(ctx, "test10@spark.com", "1", device) + if err != nil { + t.Fatal(err) + } + empty := "" + if _, err := svc.UpdateProfile(ctx, u.ID, UpdateProfileInput{Avatar: &empty}); err != nil { + t.Fatal(err) + } + u2, err := svc.AuthenticateEmail(ctx, "test10@spark.com", "1", device) + if err != nil || strings.TrimSpace(u2.Avatar) == "" { + t.Fatalf("backfill %+v %v", u2, err) + } +} + +type failObjects struct{} + +func (failObjects) Put(context.Context, string, []byte, string) error { + return errors.New("oss down") +} diff --git a/internal/module/user/logic/profile.go b/internal/module/user/logic/profile.go new file mode 100644 index 0000000..9d8a923 --- /dev/null +++ b/internal/module/user/logic/profile.go @@ -0,0 +1,181 @@ +package logic + +import ( + "context" + "strings" + "time" + + "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" +) + +// Profile is the public account view. +type Profile struct { + UID int64 `json:"uid"` + SparkID string `json:"spark_id"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + QRCode string `json:"qrcode"` + Birthday string `json:"birthday"` + Gender int `json:"gender"` + Status int `json:"status"` + LastLoginTime int64 `json:"last_login_time"` + LastActiveTime int64 `json:"last_active_time"` + CreateTime int64 `json:"create_time"` + UpdateTime int64 `json:"update_time"` +} + +func PublicProfile(u *entity.User) *Profile { + if u == nil { + return nil + } + birthday := "" + if !u.Birthday.IsZero() { + birthday = u.Birthday.Format("2006-01-02") + } + return &Profile{ + UID: u.ID, + SparkID: u.SparkID, + Nickname: u.Nickname, + Avatar: u.Avatar, + QRCode: u.QRCode, + Birthday: birthday, + Gender: u.Gender, + Status: u.Status, + LastLoginTime: unixMS(u.LastLoginAt), + LastActiveTime: unixMS(u.LastActiveAt), + CreateTime: unixMS(u.CreatedAt), + UpdateTime: unixMS(u.UpdatedAt), + } +} + +func unixMS(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.UnixMilli() +} + +func (s *Service) Get(ctx context.Context, id int64) (*entity.User, error) { + if s == nil || s.repo == nil || id <= 0 { + return nil, errs.ErrNotFound + } + return s.repo.GetByID(ctx, id) +} + +func (s *Service) GetByQRCode(ctx context.Context, qrcode string) (*entity.User, error) { + if s == nil || s.repo == nil || !entity.ValidQRCode(qrcode) { + return nil, errs.ErrNotFound + } + return s.repo.GetByQRCode(ctx, qrcode) +} + +type UpdateSparkIDInput struct { + SparkID string `json:"spark_id" validate:"required,min=6,max=32" label:"field.spark_id" errmsg:"required:validate.user.spark_id_required;min:validate.user.spark_id_invalid;max:validate.user.spark_id_invalid"` +} + +// UpdateProfileInput is a partial profile PATCH. Nil fields are left unchanged. +type UpdateProfileInput struct { + SparkID *string `json:"spark_id" validate:"omitempty,min=6,max=32" label:"field.spark_id" errmsg:"min:validate.user.spark_id_invalid;max:validate.user.spark_id_invalid"` + Nickname *string `json:"nickname" validate:"omitempty,min=1,max=64" label:"field.nickname" errmsg:"min:validate.user.nickname_invalid;max:validate.user.nickname_invalid"` + Avatar *string `json:"avatar" validate:"omitempty,max=512" label:"field.avatar" errmsg:"max:validate.user.avatar_invalid"` + Gender *int `json:"gender" validate:"omitempty,oneof=0 1 2" label:"field.gender" errmsg:"oneof:validate.user.gender_invalid"` + Birthday *string `json:"birthday" validate:"omitempty" label:"field.birthday"` +} + +func (s *Service) UpdateSparkID(ctx context.Context, userID int64, sparkID string) (*entity.User, error) { + id := sparkID + return s.UpdateProfile(ctx, userID, UpdateProfileInput{SparkID: &id}) +} + +func (s *Service) UpdateProfile(ctx context.Context, userID int64, in UpdateProfileInput) (*entity.User, error) { + if s == nil || s.repo == nil || userID <= 0 { + return nil, errs.ErrUnauthorized + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return nil, err + } + u, err := s.repo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + if !profilePatchSet(&in) { + return u, nil + } + if err := applyProfilePatch(ctx, u, &in); err != nil { + return nil, err + } + u.UpdatedAt = time.Now() + if err := s.repo.UpdateProfile(ctx, u); err != nil { + return nil, err + } + return u, nil +} + +func profilePatchSet(in *UpdateProfileInput) bool { + if in == nil { + return false + } + return in.SparkID != nil || in.Nickname != nil || in.Avatar != nil || in.Gender != nil || in.Birthday != nil +} + +func applyProfilePatch(ctx context.Context, u *entity.User, in *UpdateProfileInput) error { + if in == nil { + return nil + } + lang := reqctx.Lang(ctx) + if in.SparkID != nil { + sparkID := strings.TrimSpace(*in.SparkID) + if !entity.ValidSparkID(sparkID) { + return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUserSparkIDInvalid, nil)) + } + u.SparkID = sparkID + } + if in.Nickname != nil { + nick := strings.TrimSpace(*in.Nickname) + if nick == "" || len(nick) > 64 { + return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUserNicknameInvalid, nil)) + } + u.Nickname = nick + } + if in.Avatar != nil { + avatar := strings.TrimSpace(*in.Avatar) + if len(avatar) > 512 { + return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUserAvatarInvalid, nil)) + } + u.Avatar = avatar + } + if in.Gender != nil { + if !entity.ValidGender(*in.Gender) { + return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUserGenderInvalid, nil)) + } + u.Gender = *in.Gender + } + if in.Birthday != nil { + day, err := parseBirthday(lang, *in.Birthday) + if err != nil { + return err + } + u.Birthday = day + } + return nil +} + +func parseBirthday(lang i18n.Lang, raw string) (time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, nil + } + day, err := time.ParseInLocation("2006-01-02", raw, time.Local) + if err != nil { + return time.Time{}, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUserBirthdayInvalid, nil)) + } + today := time.Now().In(time.Local) + if day.After(today) || day.Year() < 1900 { + return time.Time{}, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUserBirthdayInvalid, nil)) + } + return day, nil +} diff --git a/internal/module/user/logic/profile_test.go b/internal/module/user/logic/profile_test.go new file mode 100644 index 0000000..037dbdd --- /dev/null +++ b/internal/module/user/logic/profile_test.go @@ -0,0 +1,74 @@ +package logic + +import ( + "errors" + "strings" + "testing" + + "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" +) + +func TestUpdateSparkID(t *testing.T) { + svc, _, _ := testService(t) + ctx := t.Context() + u, err := svc.AuthenticateEmail(ctx, "test04@spark.com", "1", &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS}) + if err != nil { + t.Fatal(err) + } + got, err := svc.UpdateSparkID(ctx, u.ID, "alice_ok") + if err != nil { + t.Fatal(err) + } + if got.SparkID != "alice_ok" { + t.Fatalf("%+v", got) + } + p := PublicProfile(got) + if p.UID != u.ID || p.SparkID != "alice_ok" || p.CreateTime == 0 || p.UpdateTime == 0 { + t.Fatalf("profile %+v", p) + } + other, err := svc.AuthenticateEmail(ctx, "test05@spark.com", "1", &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS}) + if err != nil { + t.Fatal(err) + } + _, err = svc.UpdateSparkID(ctx, other.ID, "alice_ok") + if !errors.Is(err, errs.ErrDuplicate) { + t.Fatalf("dup %v", err) + } + _, err = svc.UpdateSparkID(ctx, u.ID, "1bad") + app, ok := errs.AsAppError(err) + if !ok || app.Code != errs.ErrInvalidArgument.Code || !strings.Contains(app.Msg, "字母开头") { + t.Fatalf("invalid %v", err) + } +} + +func TestUpdateProfileFields(t *testing.T) { + svc, _, _ := testService(t) + ctx := t.Context() + u, err := svc.AuthenticateEmail(ctx, "test06@spark.com", "1", &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceWeb}) + if err != nil { + t.Fatal(err) + } + nick := "Ada" + avatar := "oss://avatar/ada" + gender := entity.GenderFemale + day := "1994-05-01" + got, err := svc.UpdateProfile(ctx, u.ID, UpdateProfileInput{ + Nickname: &nick, Avatar: &avatar, Gender: &gender, Birthday: &day, + }) + if err != nil { + t.Fatal(err) + } + p := PublicProfile(got) + if p.Nickname != "Ada" || p.Avatar != "oss://avatar/ada" || p.Gender != entity.GenderFemale || p.Birthday != "1994-05-01" { + t.Fatalf("profile %+v", p) + } + empty := "" + got, err = svc.UpdateProfile(ctx, u.ID, UpdateProfileInput{Birthday: &empty}) + if err != nil { + t.Fatal(err) + } + if !got.Birthday.IsZero() { + t.Fatalf("birthday %v", got.Birthday) + } +} diff --git a/internal/module/user/logic/service.go b/internal/module/user/logic/service.go new file mode 100644 index 0000000..208076e --- /dev/null +++ b/internal/module/user/logic/service.go @@ -0,0 +1,47 @@ +// Package logic is the user application use-case layer, split by function (profile, login, email code). +package logic + +import ( + "context" + "time" + + "spark-server/internal/module/user/entity" +) + +// Repository is user persistence. Model implements it; do not split this by table. +type Repository interface { + GetByID(ctx context.Context, id int64) (*entity.User, error) + GetByEmail(ctx context.Context, email string) (*entity.User, error) + GetBySparkID(ctx context.Context, sparkID string) (*entity.User, error) + GetByQRCode(ctx context.Context, qrcode string) (*entity.User, error) + CreateWithEmail(ctx context.Context, u *entity.User, email string) error + UpdateSparkID(ctx context.Context, userID int64, sparkID string) error + UpdateQRCode(ctx context.Context, userID int64, qrcode string) error + UpdateProfile(ctx context.Context, u *entity.User) error + TouchLogin(ctx context.Context, userID int64, at time.Time) error + UpsertDevice(ctx context.Context, d *entity.Device) error + GetDevice(ctx context.Context, userID int64, deviceID uint64) (*entity.Device, error) + InsertLoginLog(ctx context.Context, log *entity.LoginLog) error +} + +// IDGen issues time-ordered Snowflake IDs (user PK). +type IDGen interface { + NextID() (int64, error) +} + +// Objects stores the generated default avatar. Nil or Put error keeps an SVG data-URI fallback. +type Objects interface { + Put(ctx context.Context, key string, body []byte, contentType string) error +} + +// Service is the user application use-case (HTTP and TCP share it). +type Service struct { + repo Repository + codes CodeStore + ids IDGen + objects Objects +} + +func NewService(repo Repository, codes CodeStore, ids IDGen, objects Objects) *Service { + return &Service{repo: repo, codes: codes, ids: ids, objects: objects} +} diff --git a/internal/module/user/logic/service_test.go b/internal/module/user/logic/service_test.go new file mode 100644 index 0000000..7b70756 --- /dev/null +++ b/internal/module/user/logic/service_test.go @@ -0,0 +1,36 @@ +package logic + +import ( + "context" + "testing" + + "spark-server/internal/infrastructure/id" + "spark-server/internal/module/user/model" +) + +func testService(t *testing.T) (*Service, *model.MemoryRepo, *model.MemoryCodes) { + t.Helper() + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + repo := model.NewMemoryRepo() + codes := model.NewMemoryCodes() + return NewService(repo, codes, ids, newMemObjects()), repo, codes +} + +type memObjects struct { + kv map[string][]byte +} + +func newMemObjects() *memObjects { + return &memObjects{kv: map[string][]byte{}} +} + +func (m *memObjects) Put(_ context.Context, key string, body []byte, _ string) error { + if m.kv == nil { + m.kv = map[string][]byte{} + } + m.kv[key] = append([]byte(nil), body...) + return nil +} diff --git a/internal/module/user/model/email_code.go b/internal/module/user/model/email_code.go new file mode 100644 index 0000000..d54354a --- /dev/null +++ b/internal/module/user/model/email_code.go @@ -0,0 +1,68 @@ +package model + +import ( + "context" + "time" + + "github.com/redis/go-redis/v9" + + "spark-server/internal/pkg/errs" +) + +const ( + emailCodeKeyPrefix = "auth:email-code:" + emailCodeCDPrefix = "auth:email-cd:" +) + +type RedisCodes struct { + rdb *redis.Client +} + +func NewRedisCodes(rdb *redis.Client) *RedisCodes { + return &RedisCodes{rdb: rdb} +} + +func (r *RedisCodes) Put(ctx context.Context, email, code string, ttlSeconds int) error { + if r == nil || r.rdb == nil { + return errs.ErrAvailable + } + if err := r.rdb.Set(ctx, emailCodeKeyPrefix+email, code, time.Duration(ttlSeconds)*time.Second).Err(); err != nil { + return errs.ErrAvailable.WithError("email-code", err) + } + return nil +} + +func (r *RedisCodes) Get(ctx context.Context, email string) (string, error) { + if r == nil || r.rdb == nil { + return "", errs.ErrAvailable + } + v, err := r.rdb.Get(ctx, emailCodeKeyPrefix+email).Result() + if err == redis.Nil { + return "", nil + } + if err != nil { + return "", errs.ErrAvailable.WithError("email-code", err) + } + return v, nil +} + +func (r *RedisCodes) Del(ctx context.Context, email string) error { + if r == nil || r.rdb == nil { + return errs.ErrAvailable + } + if err := r.rdb.Del(ctx, emailCodeKeyPrefix+email).Err(); err != nil { + return errs.ErrAvailable.WithError("email-code", err) + } + return nil +} + +func (r *RedisCodes) SetCooldown(ctx context.Context, email string, ttlSeconds int) (bool, error) { + if r == nil || r.rdb == nil { + return false, errs.ErrAvailable + } + ok, err := r.rdb.SetNX(ctx, emailCodeCDPrefix+email, "1", time.Duration(ttlSeconds)*time.Second).Result() + if err != nil { + return false, errs.ErrAvailable.WithError("email-code", err) + } + return ok, nil +} diff --git a/internal/module/user/model/email_code_memory.go b/internal/module/user/model/email_code_memory.go new file mode 100644 index 0000000..a1921cc --- /dev/null +++ b/internal/module/user/model/email_code_memory.go @@ -0,0 +1,57 @@ +package model + +import ( + "context" + "sync" + "time" +) + +type MemoryCodes struct { + mu sync.Mutex + codes map[string]codeEntry + cooldowns map[string]time.Time +} + +type codeEntry struct { + code string + exp time.Time +} + +func NewMemoryCodes() *MemoryCodes { + return &MemoryCodes{codes: make(map[string]codeEntry), cooldowns: make(map[string]time.Time)} +} + +func (m *MemoryCodes) Put(_ context.Context, email, code string, ttlSeconds int) error { + m.mu.Lock() + defer m.mu.Unlock() + m.codes[email] = codeEntry{code: code, exp: time.Now().Add(time.Duration(ttlSeconds) * time.Second)} + return nil +} + +func (m *MemoryCodes) Get(_ context.Context, email string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + e, ok := m.codes[email] + if !ok || time.Now().After(e.exp) { + delete(m.codes, email) + return "", nil + } + return e.code, nil +} + +func (m *MemoryCodes) Del(_ context.Context, email string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.codes, email) + return nil +} + +func (m *MemoryCodes) SetCooldown(_ context.Context, email string, ttlSeconds int) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + if exp, ok := m.cooldowns[email]; ok && time.Now().Before(exp) { + return false, nil + } + m.cooldowns[email] = time.Now().Add(time.Duration(ttlSeconds) * time.Second) + return true, nil +} diff --git a/internal/module/user/model/memory.go b/internal/module/user/model/memory.go new file mode 100644 index 0000000..d758641 --- /dev/null +++ b/internal/module/user/model/memory.go @@ -0,0 +1,219 @@ +package model + +import ( + "context" + "sync" + "time" + + "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" +) + +type MemoryRepo struct { + mu sync.Mutex + byID map[int64]*entity.User + byEmail map[string]int64 + bySpark map[string]int64 + devices map[deviceKey]*entity.Device + logs []*entity.LoginLog + deviceSeq int64 + logSeq int64 +} + +type deviceKey struct { + UserID int64 + DeviceID uint64 +} + +func NewMemoryRepo() *MemoryRepo { + return &MemoryRepo{ + byID: make(map[int64]*entity.User), + byEmail: make(map[string]int64), + bySpark: make(map[string]int64), + devices: make(map[deviceKey]*entity.Device), + logs: make([]*entity.LoginLog, 0), + } +} + +func (m *MemoryRepo) GetByID(_ context.Context, id int64) (*entity.User, error) { + m.mu.Lock() + defer m.mu.Unlock() + u, ok := m.byID[id] + if !ok { + return nil, errs.ErrNotFound + } + cp := *u + return &cp, nil +} + +func (m *MemoryRepo) GetByEmail(_ context.Context, email string) (*entity.User, error) { + m.mu.Lock() + defer m.mu.Unlock() + id, ok := m.byEmail[email] + if !ok { + return nil, errs.ErrNotFound + } + cp := *m.byID[id] + return &cp, nil +} + +func (m *MemoryRepo) GetBySparkID(_ context.Context, sparkID string) (*entity.User, error) { + m.mu.Lock() + defer m.mu.Unlock() + id, ok := m.bySpark[sparkID] + if !ok { + return nil, errs.ErrNotFound + } + cp := *m.byID[id] + return &cp, nil +} + +func (m *MemoryRepo) GetByQRCode(_ context.Context, qrcode string) (*entity.User, error) { + m.mu.Lock() + defer m.mu.Unlock() + if qrcode == "" { + return nil, errs.ErrNotFound + } + for _, u := range m.byID { + if u.QRCode == qrcode { + cp := *u + return &cp, nil + } + } + return nil, errs.ErrNotFound +} + +func (m *MemoryRepo) CreateWithEmail(_ context.Context, u *entity.User, email string) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.byEmail[email]; ok { + return errs.ErrDuplicate + } + if _, ok := m.bySpark[u.SparkID]; ok { + return errs.ErrDuplicate + } + if _, ok := m.byID[u.ID]; ok { + return errs.ErrDuplicate + } + cp := *u + m.byID[u.ID] = &cp + m.byEmail[email] = u.ID + m.bySpark[u.SparkID] = u.ID + return nil +} + +func (m *MemoryRepo) UpdateSparkID(_ context.Context, userID int64, sparkID string) error { + m.mu.Lock() + defer m.mu.Unlock() + u, ok := m.byID[userID] + if !ok { + return errs.ErrNotFound + } + if other, ok := m.bySpark[sparkID]; ok && other != userID { + return errs.ErrDuplicate + } + delete(m.bySpark, u.SparkID) + u.SparkID = sparkID + u.UpdatedAt = time.Now() + m.bySpark[sparkID] = userID + return nil +} + +func (m *MemoryRepo) TouchLogin(_ context.Context, userID int64, at time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + u, ok := m.byID[userID] + if !ok { + return errs.ErrNotFound + } + u.LastLoginAt = at + u.LastActiveAt = at + return nil +} + +func (m *MemoryRepo) UpsertDevice(_ context.Context, d *entity.Device) error { + m.mu.Lock() + defer m.mu.Unlock() + key := deviceKey{UserID: d.UserID, DeviceID: d.DeviceID} + if old, ok := m.devices[key]; ok { + d.ID = old.ID + d.CreatedAt = old.CreatedAt + } else if d.ID == 0 { + m.deviceSeq++ + d.ID = m.deviceSeq + } + cp := *d + m.devices[key] = &cp + return nil +} + +func (m *MemoryRepo) GetDevice(_ context.Context, userID int64, deviceID uint64) (*entity.Device, error) { + m.mu.Lock() + defer m.mu.Unlock() + d, ok := m.devices[deviceKey{UserID: userID, DeviceID: deviceID}] + if !ok { + return nil, errs.ErrNotFound + } + cp := *d + return &cp, nil +} + +func (m *MemoryRepo) UpdateQRCode(_ context.Context, userID int64, qrcode string) error { + m.mu.Lock() + defer m.mu.Unlock() + u, ok := m.byID[userID] + if !ok { + return errs.ErrNotFound + } + u.QRCode = qrcode + return nil +} + +func (m *MemoryRepo) UpdateProfile(_ context.Context, u *entity.User) error { + if u == nil || u.ID <= 0 { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + cur, ok := m.byID[u.ID] + if !ok { + return errs.ErrNotFound + } + if other, ok := m.bySpark[u.SparkID]; ok && other != u.ID { + return errs.ErrDuplicate + } + delete(m.bySpark, cur.SparkID) + cur.SparkID = u.SparkID + cur.Nickname = u.Nickname + cur.Avatar = u.Avatar + cur.Gender = u.Gender + cur.Birthday = u.Birthday + cur.UpdatedAt = u.UpdatedAt + m.bySpark[cur.SparkID] = u.ID + return nil +} + +func (m *MemoryRepo) InsertLoginLog(_ context.Context, log *entity.LoginLog) error { + if log == nil { + return nil + } + m.mu.Lock() + defer m.mu.Unlock() + m.logSeq++ + cp := *log + cp.ID = m.logSeq + log.ID = cp.ID + m.logs = append(m.logs, &cp) + return nil +} + +func (m *MemoryRepo) LoginLogs() []*entity.LoginLog { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]*entity.LoginLog, len(m.logs)) + for i, row := range m.logs { + cp := *row + out[i] = &cp + } + return out +} diff --git a/internal/module/user/model/repo.go b/internal/module/user/model/repo.go new file mode 100644 index 0000000..0e2a69a --- /dev/null +++ b/internal/module/user/model/repo.go @@ -0,0 +1,42 @@ +// Package model is the user persistence layer. One file per table. +package model + +import ( + "context" + "errors" + + mysqldriver "github.com/go-sql-driver/mysql" + "gorm.io/gorm" + + "spark-server/internal/pkg/errs" +) + +// Repo is the MySQL implementation of user persistence. +type Repo struct { + gdb *gorm.DB +} + +func NewRepo(gdb *gorm.DB) *Repo { + return &Repo{gdb: gdb} +} + +func (m *Repo) session(ctx context.Context) (*gorm.DB, error) { + if m == nil || m.gdb == nil { + return nil, errs.ErrAvailable + } + return m.gdb.WithContext(ctx), nil +} + +func mapDBErr(err error) error { + if err == nil { + return nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return errs.ErrNotFound + } + var me *mysqldriver.MySQLError + if errors.As(err, &me) && me.Number == 1062 { + return errs.ErrDuplicate + } + return errs.ErrInner.WithError("user-mysql", err) +} diff --git a/internal/module/user/model/user.go b/internal/module/user/model/user.go new file mode 100644 index 0000000..b5d7f7e --- /dev/null +++ b/internal/module/user/model/user.go @@ -0,0 +1,191 @@ +package model + +import ( + "context" + "time" + + "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" +) + +// User maps table users: account row, Snowflake uid, mutable spark_id. +type User struct { + ID int64 `gorm:"column:id;primaryKey;comment:Snowflake 用户 id(uid),时间有序,非自增"` + SparkID string `gorm:"column:spark_id;comment:对外展示的账号,用户可改,全局唯一"` + Nickname string `gorm:"column:nickname;comment:昵称"` + Avatar string `gorm:"column:avatar;comment:头像对象存储 key 或 URL,不存图片二进制"` + QRCode string `gorm:"column:qrcode;comment:个人二维码扫描票据,随机生成,不含 uid 或 spark_id"` + Birthday *time.Time `gorm:"column:birthday;type:date;comment:生日,未填为空"` + Gender int `gorm:"column:gender;comment:0=未知 1=男 2=女"` + Status int `gorm:"column:status;comment:1=正常 0=禁用"` + LastLoginAt *time.Time `gorm:"column:last_login_at;comment:最近一次登录时间"` + LastActiveAt *time.Time `gorm:"column:last_active_at;comment:最近一次活跃时间(登录、心跳、发消息等)"` + CreatedAt time.Time `gorm:"column:created_at;comment:注册时间(create_time)"` + UpdatedAt time.Time `gorm:"column:updated_at;comment:资料最近更新时间(update_time)"` +} + +func (User) TableName() string { return "users" } + +func (r *User) entity() *entity.User { + if r == nil { + return nil + } + return &entity.User{ + ID: r.ID, + SparkID: r.SparkID, + Nickname: r.Nickname, + Avatar: r.Avatar, + QRCode: r.QRCode, + Birthday: timeOrZero(r.Birthday), + Gender: r.Gender, + Status: r.Status, + LastLoginAt: timeOrZero(r.LastLoginAt), + LastActiveAt: timeOrZero(r.LastActiveAt), + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +func fromEntity(u *entity.User) User { + row := User{ + ID: u.ID, + SparkID: u.SparkID, + Nickname: u.Nickname, + Avatar: u.Avatar, + QRCode: u.QRCode, + Gender: u.Gender, + Status: u.Status, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } + row.Birthday = timePtr(u.Birthday) + row.LastLoginAt = timePtr(u.LastLoginAt) + row.LastActiveAt = timePtr(u.LastActiveAt) + return row +} + +func timeOrZero(t *time.Time) time.Time { + if t == nil { + return time.Time{} + } + return *t +} + +func timePtr(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + return &t +} + +func (m *Repo) GetByID(ctx context.Context, id int64) (*entity.User, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row User + if err := db.Where("id = ?", id).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.entity(), nil +} + +func (m *Repo) GetBySparkID(ctx context.Context, sparkID string) (*entity.User, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row User + if err := db.Where("spark_id = ?", sparkID).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.entity(), nil +} + +func (m *Repo) GetByQRCode(ctx context.Context, qrcode string) (*entity.User, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row User + if err := db.Where("qrcode = ?", qrcode).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.entity(), nil +} + +func (m *Repo) UpdateSparkID(ctx context.Context, userID int64, sparkID string) error { + db, err := m.session(ctx) + if err != nil { + return err + } + res := db.Model(&User{}).Where("id = ?", userID).Updates(map[string]any{ + "spark_id": sparkID, + "updated_at": time.Now(), + }) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return nil +} + +func (m *Repo) UpdateQRCode(ctx context.Context, userID int64, qrcode string) error { + db, err := m.session(ctx) + if err != nil { + return err + } + res := db.Model(&User{}).Where("id = ?", userID).Update("qrcode", qrcode) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return nil +} + +func (m *Repo) UpdateProfile(ctx context.Context, u *entity.User) error { + if u == nil || u.ID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + res := db.Model(&User{}).Where("id = ?", u.ID).Select("spark_id", "nickname", "avatar", "gender", "birthday", "updated_at").Updates(map[string]any{ + "spark_id": u.SparkID, + "nickname": u.Nickname, + "avatar": u.Avatar, + "gender": u.Gender, + "birthday": timePtr(u.Birthday), + "updated_at": u.UpdatedAt, + }) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return nil +} + +func (m *Repo) TouchLogin(ctx context.Context, userID int64, at time.Time) error { + db, err := m.session(ctx) + if err != nil { + return err + } + res := db.Model(&User{}).Where("id = ?", userID).Updates(map[string]any{ + "last_login_at": at, + "last_active_at": at, + }) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return nil +} diff --git a/internal/module/user/model/user_device.go b/internal/module/user/model/user_device.go new file mode 100644 index 0000000..2ff9225 --- /dev/null +++ b/internal/module/user/model/user_device.go @@ -0,0 +1,81 @@ +package model + +import ( + "context" + "time" + + "gorm.io/gorm/clause" + + "spark-server/internal/module/user/entity" +) + +// UserDevice maps table user_devices: devices that have logged in. +type UserDevice struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 (user_id, device_id)"` + UserID int64 `gorm:"column:user_id;comment:所属用户"` + DeviceID uint64 `gorm:"column:device_id;comment:客户端设备 id,写入 JWT did"` + DeviceType string `gorm:"column:platform;comment:设备类型:ios/android/harmony/windows/macos/web"` + DeviceName string `gorm:"column:device_name;comment:设备展示名"` + AppVersion string `gorm:"column:app_version;comment:最近一次登录的客户端版本"` + Language string `gorm:"column:language;comment:最近一次登录语言,如 zh-CN"` + LastIP string `gorm:"column:last_ip;comment:最近一次登录的 PROXY RealIP,禁止用环回地址"` + LastLoginAt time.Time `gorm:"column:last_login_at;comment:最近一次登录时间"` + CreatedAt time.Time `gorm:"column:created_at;comment:该设备首次登录时间"` +} + +func (UserDevice) TableName() string { return "user_devices" } + +func (row *UserDevice) entity() *entity.Device { + if row == nil { + return nil + } + return &entity.Device{ + ID: row.ID, + UserID: row.UserID, + DeviceID: row.DeviceID, + DeviceType: row.DeviceType, + DeviceName: row.DeviceName, + AppVersion: row.AppVersion, + Language: row.Language, + LastIP: row.LastIP, + LastLoginAt: row.LastLoginAt, + CreatedAt: row.CreatedAt, + } +} + +func (m *Repo) UpsertDevice(ctx context.Context, d *entity.Device) error { + db, err := m.session(ctx) + if err != nil { + return err + } + row := UserDevice{ + UserID: d.UserID, + DeviceID: d.DeviceID, + DeviceType: d.DeviceType, + DeviceName: d.DeviceName, + AppVersion: d.AppVersion, + Language: d.Language, + LastIP: d.LastIP, + LastLoginAt: d.LastLoginAt, + CreatedAt: d.CreatedAt, + } + err = db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "device_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "platform", "device_name", "app_version", "language", "last_ip", "last_login_at", + }), + }).Create(&row).Error + return mapDBErr(err) +} + +func (m *Repo) GetDevice(ctx context.Context, userID int64, deviceID uint64) (*entity.Device, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row UserDevice + if err := db.Where("user_id = ? AND device_id = ?", userID, deviceID).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.entity(), nil +} diff --git a/internal/module/user/model/user_email.go b/internal/module/user/model/user_email.go new file mode 100644 index 0000000..519217a --- /dev/null +++ b/internal/module/user/model/user_email.go @@ -0,0 +1,49 @@ +package model + +import ( + "context" + "time" + + "gorm.io/gorm" + + "spark-server/internal/module/user/entity" +) + +// UserEmail maps table user_emails: one normalized email to one user. +type UserEmail struct { + Email string `gorm:"column:email;primaryKey;comment:规范化后的小写邮箱,登录账号"` + UserID int64 `gorm:"column:user_id;comment:绑定的 users.id,一对一"` + CreatedAt time.Time `gorm:"column:created_at;comment:绑定时间"` +} + +func (UserEmail) TableName() string { return "user_emails" } + +func (m *Repo) GetByEmail(ctx context.Context, email string) (*entity.User, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var bind UserEmail + if err := db.Where("email = ?", email).Take(&bind).Error; err != nil { + return nil, mapDBErr(err) + } + return m.GetByID(ctx, bind.UserID) +} + +func (m *Repo) CreateWithEmail(ctx context.Context, u *entity.User, email string) error { + db, err := m.session(ctx) + if err != nil { + return err + } + return db.Transaction(func(tx *gorm.DB) error { + row := fromEntity(u) + if err := tx.Create(&row).Error; err != nil { + return mapDBErr(err) + } + bind := UserEmail{Email: email, UserID: u.ID, CreatedAt: u.CreatedAt} + if err := tx.Create(&bind).Error; err != nil { + return mapDBErr(err) + } + return nil + }) +} diff --git a/internal/module/user/model/user_login_log.go b/internal/module/user/model/user_login_log.go new file mode 100644 index 0000000..6932f62 --- /dev/null +++ b/internal/module/user/model/user_login_log.go @@ -0,0 +1,48 @@ +package model + +import ( + "context" + "time" + + "spark-server/internal/module/user/entity" +) + +// UserLoginLog maps table user_login_logs: one row per successful login. +type UserLoginLog struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增"` + UserID int64 `gorm:"column:user_id;comment:登录用户"` + DeviceID uint64 `gorm:"column:device_id;comment:客户端设备 id"` + DeviceType string `gorm:"column:device_type;comment:设备类型:ios/android/harmony/windows/macos/web"` + DeviceName string `gorm:"column:device_name;comment:设备展示名"` + AppVersion string `gorm:"column:app_version;comment:客户端版本"` + Language string `gorm:"column:language;comment:登录语言"` + IP string `gorm:"column:ip;comment:PROXY RealIP"` + CreatedAt time.Time `gorm:"column:created_at;comment:本次登录时间"` +} + +func (UserLoginLog) TableName() string { return "user_login_logs" } + +func (m *Repo) InsertLoginLog(ctx context.Context, log *entity.LoginLog) error { + if log == nil { + return nil + } + db, err := m.session(ctx) + if err != nil { + return err + } + row := UserLoginLog{ + UserID: log.UserID, + DeviceID: log.DeviceID, + DeviceType: log.DeviceType, + DeviceName: log.DeviceName, + AppVersion: log.AppVersion, + Language: log.Language, + IP: log.IP, + CreatedAt: log.CreatedAt, + } + if err := db.Create(&row).Error; err != nil { + return mapDBErr(err) + } + log.ID = row.ID + return nil +} diff --git a/internal/module/user/service.go b/internal/module/user/service.go deleted file mode 100644 index ae5c53d..0000000 --- a/internal/module/user/service.go +++ /dev/null @@ -1,12 +0,0 @@ -package user - -import "context" - -type User struct { - ID int64 - Nickname string -} - -type Service interface { - Get(ctx context.Context, id int64) (*User, error) -} diff --git a/internal/pkg/errs/apperror.go b/internal/pkg/errs/apperror.go new file mode 100644 index 0000000..ee553c6 --- /dev/null +++ b/internal/pkg/errs/apperror.go @@ -0,0 +1,108 @@ +package errs + +import ( + "context" + "errors" + "fmt" +) + +// AppError is the process-wide error type. Transport maps Code/Msg into the {code,msg,data} envelope. +// InnerMsg is log-only and must not be sent to clients. +type AppError struct { + Code string `json:"code"` + Msg string `json:"msg"` + InnerMsg string `json:"inner_msg"` + LogPrint bool `json:"log_print"` + custom bool +} + +func (e *AppError) Error() string { + if e == nil { + return "" + } + return fmt.Sprintf("[%s]:%s:%s", e.Code, e.Msg, e.InnerMsg) +} + +func NewError(code, msg, innerMsg string) *AppError { + return &AppError{Code: code, Msg: msg, InnerMsg: innerMsg} +} + +func NewErrorPrint(code, msg, innerMsg string) *AppError { + return &AppError{Code: code, Msg: msg, InnerMsg: innerMsg, LogPrint: true} +} + +func (e *AppError) CodeMsg() (string, string, string) { + if e == nil { + return "", "", "" + } + return e.Code, e.Msg, e.InnerMsg +} + +// WithMsg sets the user-visible message. +func (e *AppError) WithMsg(msg string) *AppError { + if e == nil { + return nil + } + return &AppError{Code: e.Code, Msg: msg, InnerMsg: e.InnerMsg, LogPrint: e.LogPrint, custom: true} +} + +// CustomMsg is true when Msg was set by WithMsg and must not be replaced by i18n. +func (e *AppError) CustomMsg() bool { + return e != nil && e.custom && e.Msg != "" +} + +// WithInnerMsg sets the log-only internal message. +func (e *AppError) WithInnerMsg(innerMsg string) *AppError { + if e == nil { + return nil + } + return &AppError{Code: e.Code, Msg: e.Msg, InnerMsg: innerMsg, LogPrint: e.LogPrint, custom: e.custom} +} + +// WithError sets InnerMsg from a downstream error. +func (e *AppError) WithError(serviceName string, err error) *AppError { + if e == nil { + return nil + } + return &AppError{ + Code: e.Code, + Msg: e.Msg, + InnerMsg: fmt.Sprintf("[%s] err:%v", serviceName, err), + LogPrint: e.LogPrint, + custom: e.custom, + } +} + +func (e *AppError) Is(target error) bool { + t, ok := target.(*AppError) + if !ok || e == nil || t == nil { + return false + } + return e.Code == t.Code +} + +func AsAppError(err error) (*AppError, bool) { + var app *AppError + if errors.As(err, &app) && app != nil { + return app, true + } + return nil, false +} + +// FromRPCError maps a downstream call error. This process has no gRPC; context and AppError are used. +func FromRPCError(serviceName string, err error) *AppError { + if err == nil { + return nil + } + if app, ok := AsAppError(err); ok { + return app + } + switch { + case errors.Is(err, context.DeadlineExceeded): + return ErrTimeout.WithInnerMsg(fmt.Sprintf("[%s] timeout", serviceName)) + case errors.Is(err, context.Canceled): + return ErrOperatFail.WithInnerMsg(fmt.Sprintf("[%s] canceled", serviceName)) + default: + return ErrInner.WithInnerMsg(fmt.Sprintf("[%s] unexpected error: %v", serviceName, err)) + } +} diff --git a/internal/pkg/errs/apperror_test.go b/internal/pkg/errs/apperror_test.go new file mode 100644 index 0000000..fcc91e6 --- /dev/null +++ b/internal/pkg/errs/apperror_test.go @@ -0,0 +1,39 @@ +package errs + +import ( + "context" + "errors" + "testing" +) + +func TestAppErrorIsAndWith(t *testing.T) { + err := ErrNotFound.WithMsg("user gone").WithInnerMsg("uid=9") + if !errors.Is(err, ErrNotFound) { + t.Fatal("Is by code") + } + if errors.Is(err, ErrUnauthorized) { + t.Fatal("different code") + } + if err.Msg != "user gone" || ErrNotFound.Msg == "user gone" { + t.Fatal("WithMsg must copy") + } + code, msg, inner := err.CodeMsg() + if code != "40401" || msg != "user gone" || inner != "uid=9" { + t.Fatalf("CodeMsg %s %s %s", code, msg, inner) + } +} + +func TestFromRPCError(t *testing.T) { + if FromRPCError("s", nil) != nil { + t.Fatal("nil") + } + if got := FromRPCError("s", ErrBusy); !errors.Is(got, ErrBusy) { + t.Fatal("passthrough") + } + if got := FromRPCError("user", context.DeadlineExceeded); !errors.Is(got, ErrTimeout) { + t.Fatal("timeout") + } + if got := FromRPCError("user", errors.New("boom")); !errors.Is(got, ErrInner) { + t.Fatal("inner") + } +} diff --git a/internal/pkg/errs/codes.go b/internal/pkg/errs/codes.go new file mode 100644 index 0000000..0b33b40 --- /dev/null +++ b/internal/pkg/errs/codes.go @@ -0,0 +1,28 @@ +package errs + +// Sentinel AppErrors. Code is the envelope code shared by HTTP and TCP. +var ( + OK = NewError("0", "ok", "") + ErrInvalidArgument = NewError("40001", "invalid argument", "") + ErrFrameTooLarge = NewError("40002", "frame too large", "") + ErrInvalidFrame = NewError("40003", "invalid frame", "") + ErrProxyHeader = NewError("40004", "invalid proxy protocol header", "") + ErrUnauthorized = NewError("40101", "unauthorized", "") + ErrNotAuthenticated = NewError("40102", "not authenticated", "") + ErrForbidden = NewError("40301", "forbidden", "") + ErrNotFound = NewError("40401", "not found", "") + ErrMediaExpired = NewError("40402", "media expired", "") + ErrDuplicate = NewError("40901", "duplicate", "") + ErrBusy = NewError("42901", "busy", "") + ErrRateLimited = NewError("42902", "rate limited", "") + ErrOutboundDropped = NewError("42903", "outbound dropped", "") + ErrClosed = NewError("49901", "connection closed", "") + ErrInner = NewErrorPrint("50001", "internal", "") + ErrOperatFail = NewErrorPrint("50002", "operation failed", "") + ErrClockRewind = NewErrorPrint("50003", "snowflake clock rewind", "") + ErrAvailable = NewErrorPrint("50301", "unavailable", "") + ErrTimeout = NewErrorPrint("50401", "timeout", "") +) + +// ErrInternal is an alias of ErrInner. +var ErrInternal = ErrInner diff --git a/internal/pkg/errs/errs.go b/internal/pkg/errs/errs.go index 803ab70..09052ae 100644 --- a/internal/pkg/errs/errs.go +++ b/internal/pkg/errs/errs.go @@ -1,22 +1,2 @@ -// Package errs is the shared domain error set. Transport maps these to protocol codes. +// Package errs is the shared domain error set. Transport maps these to the {code,msg,data} envelope. package errs - -import "errors" - -var ( - ErrUnauthorized = errors.New("unauthorized") - ErrNotAuthenticated = errors.New("not authenticated") - ErrForbidden = errors.New("forbidden") - ErrNotFound = errors.New("not found") - ErrInvalidArgument = errors.New("invalid argument") - ErrDuplicate = errors.New("duplicate") - ErrRateLimited = errors.New("rate limited") - ErrBusy = errors.New("busy") - ErrInternal = errors.New("internal") - ErrFrameTooLarge = errors.New("frame too large") - ErrInvalidFrame = errors.New("invalid frame") - ErrProxyHeader = errors.New("invalid proxy protocol header") - ErrClockRewind = errors.New("snowflake clock rewind") - ErrClosed = errors.New("connection closed") - ErrOutboundDropped = errors.New("outbound dropped") -) diff --git a/internal/pkg/i18n/catalog.go b/internal/pkg/i18n/catalog.go new file mode 100644 index 0000000..e414f5e --- /dev/null +++ b/internal/pkg/i18n/catalog.go @@ -0,0 +1,9 @@ +package i18n + +var catalog = map[string]map[Lang]string{} + +func register(entries map[string]map[Lang]string) { + for k, v := range entries { + catalog[k] = v + } +} diff --git a/internal/pkg/i18n/catalog_err.go b/internal/pkg/i18n/catalog_err.go new file mode 100644 index 0000000..b4856bb --- /dev/null +++ b/internal/pkg/i18n/catalog_err.go @@ -0,0 +1,66 @@ +package i18n + +func init() { + register(map[string]map[Lang]string{ + KeyOK: { + ZhCN: "ok", En: "ok", ZhTW: "ok", + }, + "err.40001": { + ZhCN: "参数错误", En: "invalid argument", ZhTW: "參數錯誤", + }, + "err.40002": { + ZhCN: "数据过大", En: "frame too large", ZhTW: "資料過大", + }, + "err.40003": { + ZhCN: "数据格式错误", En: "invalid frame", ZhTW: "資料格式錯誤", + }, + "err.40004": { + ZhCN: "代理协议错误", En: "invalid proxy protocol header", ZhTW: "代理協定錯誤", + }, + "err.40101": { + ZhCN: "未授权", En: "unauthorized", ZhTW: "未授權", + }, + "err.40102": { + ZhCN: "未登录", En: "not authenticated", ZhTW: "未登入", + }, + "err.40301": { + ZhCN: "无权限", En: "forbidden", ZhTW: "無權限", + }, + "err.40401": { + ZhCN: "未找到", En: "not found", ZhTW: "未找到", + }, + "err.40402": { + ZhCN: "原文件已过期", En: "media expired", ZhTW: "原檔案已過期", + }, + "err.40901": { + ZhCN: "重复", En: "duplicate", ZhTW: "重複", + }, + "err.42901": { + ZhCN: "系统繁忙", En: "busy", ZhTW: "系統繁忙", + }, + "err.42902": { + ZhCN: "请求过于频繁", En: "rate limited", ZhTW: "請求過於頻繁", + }, + "err.42903": { + ZhCN: "发送队列已满", En: "outbound dropped", ZhTW: "傳送佇列已滿", + }, + "err.49901": { + ZhCN: "连接已关闭", En: "connection closed", ZhTW: "連線已關閉", + }, + "err.50001": { + ZhCN: "内部错误", En: "internal", ZhTW: "內部錯誤", + }, + "err.50002": { + ZhCN: "操作失败", En: "operation failed", ZhTW: "操作失敗", + }, + "err.50003": { + ZhCN: "时钟回拨", En: "snowflake clock rewind", ZhTW: "時鐘回撥", + }, + "err.50301": { + ZhCN: "服务不可用", En: "unavailable", ZhTW: "服務不可用", + }, + "err.50401": { + ZhCN: "超时", En: "timeout", ZhTW: "逾時", + }, + }) +} diff --git a/internal/pkg/i18n/catalog_notify.go b/internal/pkg/i18n/catalog_notify.go new file mode 100644 index 0000000..8666063 --- /dev/null +++ b/internal/pkg/i18n/catalog_notify.go @@ -0,0 +1,42 @@ +package i18n + +func init() { + register(map[string]map[Lang]string{ + NotifyKickReplaced: { + ZhCN: "已在其他设备登录", En: "Signed in on another device", ZhTW: "已在其他裝置登入", + }, + NotifyKickLogout: { + ZhCN: "已退出登录", En: "Signed out", ZhTW: "已登出", + }, + NotifyFriendRequest + ".title": { + ZhCN: "好友请求", En: "Friend request", ZhTW: "好友請求", + }, + NotifyFriendRequest + ".body": { + ZhCN: "{name} 请求添加你为好友", En: "{name} sent you a friend request", ZhTW: "{name} 請求加入你好友", + }, + NotifyFriendBecame + ".title": { + ZhCN: "已成为好友", En: "You are now friends", ZhTW: "已成為好友", + }, + NotifyFriendBecame + ".body": { + ZhCN: "你们已成为好友", En: "You are now friends", ZhTW: "你們已成為好友", + }, + NotifyGroupInvite + ".title": { + ZhCN: "群邀请", En: "Group invite", ZhTW: "群邀請", + }, + NotifyGroupInvite + ".body": { + ZhCN: "{name} 邀请你加入 {group}", En: "{name} invited you to {group}", ZhTW: "{name} 邀請你加入 {group}", + }, + NotifyGroupRemoved + ".title": { + ZhCN: "移出群聊", En: "Removed from group", ZhTW: "移出群組", + }, + NotifyGroupRemoved + ".body": { + ZhCN: "你已被移出 {group}", En: "You were removed from {group}", ZhTW: "你已被移出 {group}", + }, + NotifyMessageRecall + ".title": { + ZhCN: "消息撤回", En: "Message recalled", ZhTW: "訊息撤回", + }, + NotifyMessageRecall + ".body": { + ZhCN: "有一条消息被撤回", En: "A message was recalled", ZhTW: "有一則訊息被撤回", + }, + }) +} diff --git a/internal/pkg/i18n/catalog_validate.go b/internal/pkg/i18n/catalog_validate.go new file mode 100644 index 0000000..50ba9f6 --- /dev/null +++ b/internal/pkg/i18n/catalog_validate.go @@ -0,0 +1,96 @@ +package i18n + +func init() { + register(map[string]map[Lang]string{ + KeyValidateInvalidJSON: { + ZhCN: "请求体不是合法 JSON", En: "request body is not valid JSON", ZhTW: "請求體不是合法 JSON", + }, + KeyValidateFailed: { + ZhCN: "参数校验失败", En: "validation failed", ZhTW: "參數校驗失敗", + }, + KeyValidateRequired: { + ZhCN: "{label}不能为空", En: "{label} is required", ZhTW: "{label}不能為空", + }, + KeyValidateLen: { + ZhCN: "{label}长度必须是{param}", En: "{label} must be length {param}", ZhTW: "{label}長度必須是{param}", + }, + KeyValidateMin: { + ZhCN: "{label}不能少于{param}", En: "{label} must be at least {param}", ZhTW: "{label}不能少於{param}", + }, + KeyValidateMax: { + ZhCN: "{label}不能大于{param}", En: "{label} must be at most {param}", ZhTW: "{label}不能大於{param}", + }, + KeyValidateGt: { + ZhCN: "{label}必须大于{param}", En: "{label} must be greater than {param}", ZhTW: "{label}必須大於{param}", + }, + KeyValidateGte: { + ZhCN: "{label}必须大于等于{param}", En: "{label} must be greater than or equal to {param}", ZhTW: "{label}必須大於等於{param}", + }, + KeyValidateLt: { + ZhCN: "{label}必须小于{param}", En: "{label} must be less than {param}", ZhTW: "{label}必須小於{param}", + }, + KeyValidateLte: { + ZhCN: "{label}必须小于等于{param}", En: "{label} must be less than or equal to {param}", ZhTW: "{label}必須小於等於{param}", + }, + KeyValidateEmail: { + ZhCN: "{label}格式不正确", En: "{label} is not a valid email", ZhTW: "{label}格式不正確", + }, + KeyValidateOneof: { + ZhCN: "{label}必须是{param}之一", En: "{label} must be one of {param}", ZhTW: "{label}必須是{param}之一", + }, + KeyValidateDefault: { + ZhCN: "{label}校验失败", En: "{label} is invalid", ZhTW: "{label}校驗失敗", + }, + KeyFieldPhone: { + ZhCN: "手机号", En: "Phone number", ZhTW: "手機號", + }, + KeyFieldPassword: { + ZhCN: "密码", En: "Password", ZhTW: "密碼", + }, + KeyFieldDeviceID: { + ZhCN: "设备ID", En: "Device ID", ZhTW: "裝置ID", + }, + KeyFieldEmail: { + ZhCN: "邮箱", En: "Email", ZhTW: "郵箱", + }, + KeyFieldVerifyCode: { + ZhCN: "验证码", En: "Verification code", ZhTW: "驗證碼", + }, + KeyFieldSparkID: { + ZhCN: "Spark账号", En: "Spark ID", ZhTW: "Spark帳號", + }, + KeyFieldPlatform: { + ZhCN: "平台", En: "Platform", ZhTW: "平台", + }, + KeyFieldDeviceName: { + ZhCN: "设备名称", En: "Device name", ZhTW: "裝置名稱", + }, + KeyLoginPhoneRequired: { + ZhCN: "请填写手机号", En: "Please enter your phone number", ZhTW: "請填寫手機號", + }, + KeyLoginPasswordRequired: { + ZhCN: "请填写密码", En: "Please enter your password", ZhTW: "請填寫密碼", + }, + KeyLoginPasswordMin: { + ZhCN: "密码至少6位", En: "Password must be at least 6 characters", ZhTW: "密碼至少6位", + }, + KeyLoginDeviceRequired: { + ZhCN: "请选择设备", En: "Please choose a device", ZhTW: "請選擇裝置", + }, + KeyLoginEmailRequired: { + ZhCN: "请填写邮箱", En: "Please enter your email", ZhTW: "請填寫郵箱", + }, + KeyLoginEmailInvalid: { + ZhCN: "邮箱格式不正确", En: "Please enter a valid email", ZhTW: "郵箱格式不正確", + }, + KeyLoginCodeRequired: { + ZhCN: "请填写验证码", En: "Please enter the verification code", ZhTW: "請填寫驗證碼", + }, + KeyUserSparkIDRequired: { + ZhCN: "请填写Spark账号", En: "Please enter a Spark ID", ZhTW: "請填寫Spark帳號", + }, + KeyUserSparkIDInvalid: { + ZhCN: "Spark账号需以字母开头,6-32位字母数字或下划线", En: "Spark ID must start with a letter and be 6-32 letters, digits, or underscores", ZhTW: "Spark帳號需以字母開頭,6-32位字母數字或底線", + }, + }) +} diff --git a/internal/pkg/i18n/catalog_validate_friend.go b/internal/pkg/i18n/catalog_validate_friend.go new file mode 100644 index 0000000..94508a0 --- /dev/null +++ b/internal/pkg/i18n/catalog_validate_friend.go @@ -0,0 +1,66 @@ +package i18n + +func init() { + register(map[string]map[Lang]string{ + KeyFieldUID: { + ZhCN: "用户", En: "User", ZhTW: "使用者", + }, + KeyFieldQRCode: { + ZhCN: "二维码", En: "QR code", ZhTW: "二維碼", + }, + KeyFieldReason: { + ZhCN: "申请理由", En: "Request message", ZhTW: "申請理由", + }, + KeyFieldRemark: { + ZhCN: "备注", En: "Remark", ZhTW: "備註", + }, + KeyFieldRequestID: { + ZhCN: "好友申请", En: "Friend request", ZhTW: "好友申請", + }, + KeyFieldReportReason: { + ZhCN: "投诉类型", En: "Report reason", ZhTW: "投訴類型", + }, + KeyFieldReportDetail: { + ZhCN: "投诉说明", En: "Report detail", ZhTW: "投訴說明", + }, + KeyFriendTargetRequired: { + ZhCN: "请填写邮箱、Spark账号或二维码其中一项", En: "Provide exactly one of email, Spark ID, or QR code", ZhTW: "請填寫郵箱、Spark帳號或二維碼其中一項", + }, + KeyFriendQRInvalid: { + ZhCN: "二维码无效", En: "Invalid QR code", ZhTW: "二維碼無效", + }, + KeyFriendReasonInvalid: { + ZhCN: "申请理由过长", En: "Request message is too long", ZhTW: "申請理由過長", + }, + KeyFriendRemarkInvalid: { + ZhCN: "备注过长", En: "Remark is too long", ZhTW: "備註過長", + }, + KeyFriendRequestRequired: { + ZhCN: "请选择好友申请", En: "Please choose a friend request", ZhTW: "請選擇好友申請", + }, + KeyFriendReportReasonRequired: { + ZhCN: "请选择投诉类型", En: "Please choose a report reason", ZhTW: "請選擇投訴類型", + }, + KeyFriendReportReasonInvalid: { + ZhCN: "投诉类型无效", En: "Invalid report reason", ZhTW: "投訴類型無效", + }, + KeyFriendReportDetailInvalid: { + ZhCN: "投诉说明过长", En: "Report detail is too long", ZhTW: "投訴說明過長", + }, + KeyFriendSelf: { + ZhCN: "不能添加自己为好友", En: "You cannot add yourself", ZhTW: "不能將自己加為好友", + }, + KeyFriendAlready: { + ZhCN: "已经是好友", En: "Already friends", ZhTW: "已經是好友", + }, + KeyFriendBlocked: { + ZhCN: "已拉黑该用户", En: "You have blocked this user", ZhTW: "已封鎖該使用者", + }, + KeyFriendNotFriend: { + ZhCN: "还不是好友", En: "Not friends", ZhTW: "還不是好友", + }, + KeyFriendRequestInvalid: { + ZhCN: "好友申请无效", En: "Friend request is invalid", ZhTW: "好友申請無效", + }, + }) +} diff --git a/internal/pkg/i18n/catalog_validate_group.go b/internal/pkg/i18n/catalog_validate_group.go new file mode 100644 index 0000000..fbd8bb2 --- /dev/null +++ b/internal/pkg/i18n/catalog_validate_group.go @@ -0,0 +1,60 @@ +package i18n + +func init() { + register(map[string]map[Lang]string{ + KeyFieldGroupName: { + ZhCN: "群名称", En: "Group name", ZhTW: "群名稱", + }, + KeyFieldMemberUIDs: { + ZhCN: "群成员", En: "Group members", ZhTW: "群成員", + }, + KeyFieldRole: { + ZhCN: "角色", En: "Role", ZhTW: "角色", + }, + KeyFieldGroupNickname: { + ZhCN: "群昵称", En: "Group nickname", ZhTW: "群暱稱", + }, + KeyGroupNameRequired: { + ZhCN: "请填写群名称", En: "Please enter a group name", ZhTW: "請填寫群名稱", + }, + KeyGroupNameInvalid: { + ZhCN: "群名称需 1-32 个字符", En: "Group name must be 1-32 characters", ZhTW: "群名稱需 1-32 個字元", + }, + KeyGroupMembersRequired: { + ZhCN: "请选择至少一名好友", En: "Choose at least one friend", ZhTW: "請選擇至少一名好友", + }, + KeyGroupMembersInvalid: { + ZhCN: "邀请人数过多", En: "Too many members", ZhTW: "邀請人數過多", + }, + KeyGroupUIDRequired: { + ZhCN: "请选择成员", En: "Please choose a member", ZhTW: "請選擇成員", + }, + KeyGroupRoleInvalid: { + ZhCN: "角色无效", En: "Invalid role", ZhTW: "角色無效", + }, + KeyGroupNicknameInvalid: { + ZhCN: "群昵称过长", En: "Group nickname is too long", ZhTW: "群暱稱過長", + }, + KeyGroupNotMember: { + ZhCN: "不是群成员", En: "Not a group member", ZhTW: "不是群成員", + }, + KeyGroupNotFriend: { + ZhCN: "只能邀请好友入群", En: "You can only invite friends", ZhTW: "只能邀請好友入群", + }, + KeyGroupOwnerLeave: { + ZhCN: "群主需先转让群主", En: "Transfer ownership before leaving", ZhTW: "群主需先轉讓群主", + }, + KeyGroupFull: { + ZhCN: "群人数已满", En: "Group is full", ZhTW: "群人數已滿", + }, + KeyGroupDissolved: { + ZhCN: "群已解散", En: "Group has been dissolved", ZhTW: "群已解散", + }, + KeyGroupRoleDenied: { + ZhCN: "没有操作权限", En: "You cannot do that", ZhTW: "沒有操作權限", + }, + KeyGroupAlreadyMember: { + ZhCN: "已经是群成员", En: "Already a group member", ZhTW: "已經是群成員", + }, + }) +} diff --git a/internal/pkg/i18n/catalog_validate_upload.go b/internal/pkg/i18n/catalog_validate_upload.go new file mode 100644 index 0000000..b6e6ccd --- /dev/null +++ b/internal/pkg/i18n/catalog_validate_upload.go @@ -0,0 +1,60 @@ +package i18n + +func init() { + register(map[string]map[Lang]string{ + KeyFieldPurpose: { + ZhCN: "用途", En: "Purpose", ZhTW: "用途", + }, + KeyFieldMime: { + ZhCN: "文件类型", En: "MIME type", ZhTW: "檔案類型", + }, + KeyFieldSize: { + ZhCN: "文件大小", En: "File size", ZhTW: "檔案大小", + }, + KeyFieldObjectKey: { + ZhCN: "对象键", En: "Object key", ZhTW: "物件鍵", + }, + KeyFieldMsgID: { + ZhCN: "消息", En: "Message", ZhTW: "訊息", + }, + KeyUploadPurposeRequired: { + ZhCN: "请选择上传用途", En: "Please choose an upload purpose", ZhTW: "請選擇上傳用途", + }, + KeyUploadPurposeInvalid: { + ZhCN: "上传用途无效", En: "Invalid upload purpose", ZhTW: "上傳用途無效", + }, + KeyUploadMIMERequired: { + ZhCN: "请填写文件类型", En: "Please provide a MIME type", ZhTW: "請填寫檔案類型", + }, + KeyUploadMIMEInvalid: { + ZhCN: "不支持该文件类型", En: "This file type is not allowed", ZhTW: "不支援該檔案類型", + }, + KeyUploadSizeRequired: { + ZhCN: "请填写文件大小", En: "Please provide the file size", ZhTW: "請填寫檔案大小", + }, + KeyUploadSizeInvalid: { + ZhCN: "文件大小无效", En: "Invalid file size", ZhTW: "檔案大小無效", + }, + KeyUploadSizeExceeded: { + ZhCN: "文件过大", En: "File is too large", ZhTW: "檔案過大", + }, + KeyUploadKeyRequired: { + ZhCN: "请填写对象键", En: "Please provide an object key", ZhTW: "請填寫物件鍵", + }, + KeyUploadKeyInvalid: { + ZhCN: "对象键无效", En: "Invalid object key", ZhTW: "物件鍵無效", + }, + KeyFieldGroupID: { + ZhCN: "群", En: "Group", ZhTW: "群", + }, + KeyUploadGroupRequired: { + ZhCN: "请选择群", En: "Please choose a group", ZhTW: "請選擇群", + }, + KeyFavoriteMsgRequired: { + ZhCN: "请选择消息", En: "Please choose a message", ZhTW: "請選擇訊息", + }, + KeyFavoriteMsgInvalid: { + ZhCN: "消息无效", En: "Invalid message", ZhTW: "訊息無效", + }, + }) +} diff --git a/internal/pkg/i18n/catalog_validate_user.go b/internal/pkg/i18n/catalog_validate_user.go new file mode 100644 index 0000000..ed17723 --- /dev/null +++ b/internal/pkg/i18n/catalog_validate_user.go @@ -0,0 +1,45 @@ +package i18n + +func init() { + register(map[string]map[Lang]string{ + KeyFieldDeviceType: { + ZhCN: "设备类型", En: "Device type", ZhTW: "裝置類型", + }, + KeyFieldRefreshToken: { + ZhCN: "刷新令牌", En: "Refresh token", ZhTW: "重新整理令牌", + }, + KeyFieldNickname: { + ZhCN: "昵称", En: "Nickname", ZhTW: "暱稱", + }, + KeyFieldAvatar: { + ZhCN: "头像", En: "Avatar", ZhTW: "頭像", + }, + KeyFieldGender: { + ZhCN: "性别", En: "Gender", ZhTW: "性別", + }, + KeyFieldBirthday: { + ZhCN: "生日", En: "Birthday", ZhTW: "生日", + }, + KeyUserNicknameInvalid: { + ZhCN: "昵称长度为1-64", En: "Nickname must be 1-64 characters", ZhTW: "暱稱長度為1-64", + }, + KeyUserAvatarInvalid: { + ZhCN: "头像地址过长", En: "Avatar is too long", ZhTW: "頭像地址過長", + }, + KeyUserGenderInvalid: { + ZhCN: "性别取值无效", En: "Invalid gender", ZhTW: "性別取值無效", + }, + KeyUserBirthdayInvalid: { + ZhCN: "生日格式为YYYY-MM-DD", En: "Birthday must be YYYY-MM-DD", ZhTW: "生日格式為YYYY-MM-DD", + }, + KeyLoginDeviceTypeRequired: { + ZhCN: "请选择设备类型", En: "Please choose a device type", ZhTW: "請選擇裝置類型", + }, + KeyLoginDeviceTypeInvalid: { + ZhCN: "设备类型无效", En: "Invalid device type", ZhTW: "裝置類型無效", + }, + KeyLoginRefreshRequired: { + ZhCN: "请填写刷新令牌", En: "Please enter the refresh token", ZhTW: "請填寫重新整理令牌", + }, + }) +} diff --git a/internal/pkg/i18n/i18n_test.go b/internal/pkg/i18n/i18n_test.go new file mode 100644 index 0000000..2d3accf --- /dev/null +++ b/internal/pkg/i18n/i18n_test.go @@ -0,0 +1,41 @@ +package i18n + +import "testing" + +func TestParseLanguage(t *testing.T) { + cases := map[string]Lang{ + "": ZhCN, + "zh": ZhCN, + "zh-CN": ZhCN, + "zh_CN": ZhCN, + "zh-Hans": ZhCN, + "en": En, + "en-US,en;q=0.9": En, + "zh-TW": ZhTW, + "zh-HK": ZhTW, + "zh-Hant-TW": ZhTW, + "fr,en;q=0.8": En, + "zh-CN,zh;q=0.9,en;q=0.8": ZhCN, + } + for in, want := range cases { + if got := Parse(in); got != want { + t.Fatalf("Parse(%q)=%s want %s", in, got, want) + } + } +} + +func TestErrorAndNotifyI18n(t *testing.T) { + if ErrorMsg(ZhCN, "40101") != "未授权" || ErrorMsg(En, "40101") != "unauthorized" || ErrorMsg(ZhTW, "40101") != "未授權" { + t.Fatalf("err 40101 %s %s %s", ErrorMsg(ZhCN, "40101"), ErrorMsg(En, "40101"), ErrorMsg(ZhTW, "40101")) + } + if ErrorMsg(ZhCN, "40402") != "原文件已过期" || ErrorMsg(En, "40402") != "media expired" { + t.Fatalf("err 40402 %s %s", ErrorMsg(ZhCN, "40402"), ErrorMsg(En, "40402")) + } + title, body := Notify(En, NotifyFriendRequest, map[string]string{"name": "Ada"}) + if title != "Friend request" || body != "Ada sent you a friend request" { + t.Fatalf("notify %q %q", title, body) + } + if T(ZhTW, NotifyKickReplaced, nil) != "已在其他裝置登入" { + t.Fatalf("kick %s", T(ZhTW, NotifyKickReplaced, nil)) + } +} diff --git a/internal/pkg/i18n/keys.go b/internal/pkg/i18n/keys.go new file mode 100644 index 0000000..1592158 --- /dev/null +++ b/internal/pkg/i18n/keys.go @@ -0,0 +1,119 @@ +package i18n + +// Message keys. Error keys are "err." + AppError.Code. +const ( + KeyOK = "ok" + + KeyValidateInvalidJSON = "validate.invalid_json" + KeyValidateFailed = "validate.failed" + KeyValidateRequired = "validate.required" + KeyValidateLen = "validate.len" + KeyValidateMin = "validate.min" + KeyValidateMax = "validate.max" + KeyValidateGt = "validate.gt" + KeyValidateGte = "validate.gte" + KeyValidateLt = "validate.lt" + KeyValidateLte = "validate.lte" + KeyValidateEmail = "validate.email" + KeyValidateOneof = "validate.oneof" + KeyValidateDefault = "validate.default" + + KeyFieldPhone = "field.phone" + KeyFieldPassword = "field.password" + KeyFieldDeviceID = "field.device_id" + KeyFieldEmail = "field.email" + KeyFieldVerifyCode = "field.verify_code" + KeyFieldSparkID = "field.spark_id" + KeyFieldPlatform = "field.platform" + KeyFieldDeviceName = "field.device_name" + KeyFieldDeviceType = "field.device_type" + KeyFieldRefreshToken = "field.refresh_token" + KeyFieldNickname = "field.nickname" + KeyFieldAvatar = "field.avatar" + KeyFieldGender = "field.gender" + KeyFieldBirthday = "field.birthday" + KeyFieldUID = "field.uid" + KeyFieldQRCode = "field.qrcode" + KeyFieldReason = "field.reason" + KeyFieldRemark = "field.remark" + KeyFieldRequestID = "field.request_id" + KeyFieldReportReason = "field.report_reason" + KeyFieldReportDetail = "field.report_detail" + KeyFieldPurpose = "field.purpose" + KeyFieldMime = "field.mime" + KeyFieldSize = "field.size" + KeyFieldObjectKey = "field.object_key" + KeyFieldMsgID = "field.msg_id" + KeyFieldGroupID = "field.group_id" + + KeyLoginPhoneRequired = "validate.login.phone_required" + KeyLoginPasswordRequired = "validate.login.password_required" + KeyLoginPasswordMin = "validate.login.password_min" + KeyLoginDeviceRequired = "validate.login.device_required" + KeyLoginEmailRequired = "validate.login.email_required" + KeyLoginEmailInvalid = "validate.login.email_invalid" + KeyLoginCodeRequired = "validate.login.code_required" + KeyUserSparkIDRequired = "validate.user.spark_id_required" + KeyUserSparkIDInvalid = "validate.user.spark_id_invalid" + KeyUserNicknameInvalid = "validate.user.nickname_invalid" + KeyUserAvatarInvalid = "validate.user.avatar_invalid" + KeyUserGenderInvalid = "validate.user.gender_invalid" + KeyUserBirthdayInvalid = "validate.user.birthday_invalid" + KeyLoginDeviceTypeRequired = "validate.login.device_type_required" + KeyLoginDeviceTypeInvalid = "validate.login.device_type_invalid" + KeyLoginRefreshRequired = "validate.login.refresh_required" + + KeyFriendTargetRequired = "validate.friend.target_required" + KeyFriendQRInvalid = "validate.friend.qrcode_invalid" + KeyFriendReasonInvalid = "validate.friend.reason_invalid" + KeyFriendRemarkInvalid = "validate.friend.remark_invalid" + KeyFriendRequestRequired = "validate.friend.request_required" + KeyFriendReportReasonRequired = "validate.friend.report_reason_required" + KeyFriendReportReasonInvalid = "validate.friend.report_reason_invalid" + KeyFriendReportDetailInvalid = "validate.friend.report_detail_invalid" + KeyFriendSelf = "friend.self" + KeyFriendAlready = "friend.already" + KeyFriendBlocked = "friend.blocked" + KeyFriendNotFriend = "friend.not_friend" + KeyFriendRequestInvalid = "friend.request_invalid" + + KeyUploadPurposeRequired = "validate.upload.purpose_required" + KeyUploadPurposeInvalid = "validate.upload.purpose_invalid" + KeyUploadMIMERequired = "validate.upload.mime_required" + KeyUploadMIMEInvalid = "validate.upload.mime_invalid" + KeyUploadSizeRequired = "validate.upload.size_required" + KeyUploadSizeInvalid = "validate.upload.size_invalid" + KeyUploadSizeExceeded = "validate.upload.size_exceeded" + KeyUploadKeyRequired = "validate.upload.key_required" + KeyUploadKeyInvalid = "validate.upload.key_invalid" + KeyUploadGroupRequired = "validate.upload.group_required" + KeyFavoriteMsgRequired = "validate.favorite.msg_required" + KeyFavoriteMsgInvalid = "validate.favorite.msg_invalid" + + KeyFieldGroupName = "field.group_name" + KeyFieldMemberUIDs = "field.member_uids" + KeyFieldRole = "field.role" + KeyFieldGroupNickname = "field.group_nickname" + KeyGroupNameRequired = "validate.group.name_required" + KeyGroupNameInvalid = "validate.group.name_invalid" + KeyGroupMembersRequired = "validate.group.members_required" + KeyGroupMembersInvalid = "validate.group.members_invalid" + KeyGroupUIDRequired = "validate.group.uid_required" + KeyGroupRoleInvalid = "validate.group.role_invalid" + KeyGroupNicknameInvalid = "validate.group.nickname_invalid" + KeyGroupNotMember = "group.not_member" + KeyGroupNotFriend = "group.not_friend" + KeyGroupOwnerLeave = "group.owner_leave" + KeyGroupFull = "group.full" + KeyGroupDissolved = "group.dissolved" + KeyGroupRoleDenied = "group.role_denied" + KeyGroupAlreadyMember = "group.already_member" + + NotifyKickReplaced = "notify.kick.replaced" + NotifyKickLogout = "notify.kick.logout" + NotifyFriendRequest = "notify.friend.request" + NotifyFriendBecame = "notify.friend.became" + NotifyGroupInvite = "notify.group.invite" + NotifyGroupRemoved = "notify.group.removed" + NotifyMessageRecall = "notify.message.recall" +) diff --git a/internal/pkg/i18n/locale.go b/internal/pkg/i18n/locale.go new file mode 100644 index 0000000..a2f2a2d --- /dev/null +++ b/internal/pkg/i18n/locale.go @@ -0,0 +1,50 @@ +package i18n + +import ( + "strings" +) + +// Lang is a canonical UI language. Only these three are served. +type Lang string + +const ( + ZhCN Lang = "zh-CN" + En Lang = "en" + ZhTW Lang = "zh-TW" + Default = ZhCN +) + +func (l Lang) String() string { + if l == "" { + return string(Default) + } + return string(l) +} + +// Parse maps Accept-Language, X-Language, or TCP AUTH language to ZhCN / En / ZhTW. +func Parse(raw string) Lang { + for _, part := range strings.Split(raw, ",") { + tag, _, _ := strings.Cut(strings.TrimSpace(part), ";") + if lang, ok := match(tag); ok { + return lang + } + } + return Default +} + +func match(tag string) (Lang, bool) { + t := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(tag), "_", "-")) + if t == "" { + return "", false + } + switch { + case t == "en" || strings.HasPrefix(t, "en-"): + return En, true + case t == "zh-tw" || t == "zh-hk" || t == "zh-mo" || t == "zh-hant" || strings.HasPrefix(t, "zh-hant"): + return ZhTW, true + case t == "zh" || t == "zh-cn" || t == "zh-sg" || t == "zh-hans" || strings.HasPrefix(t, "zh-hans"): + return ZhCN, true + default: + return "", false + } +} diff --git a/internal/pkg/i18n/text.go b/internal/pkg/i18n/text.go new file mode 100644 index 0000000..6d2db5f --- /dev/null +++ b/internal/pkg/i18n/text.go @@ -0,0 +1,62 @@ +package i18n + +import "strings" + +func T(lang Lang, key string, args map[string]string) string { + if key == "" { + return "" + } + if lang == "" { + lang = Default + } + s := lookup(lang, key) + if s == "" && lang != En { + s = lookup(En, key) + } + if s == "" && lang != ZhCN { + s = lookup(ZhCN, key) + } + if s == "" { + return key + } + return apply(s, args) +} + +func lookup(lang Lang, key string) string { + byLang, ok := catalog[key] + if !ok { + return "" + } + return byLang[lang] +} + +func apply(s string, args map[string]string) string { + for k, v := range args { + s = strings.ReplaceAll(s, "{"+k+"}", v) + } + return s +} + +// ErrorMsg is the user-visible envelope msg for a business code. +func ErrorMsg(lang Lang, code string) string { + if code == "" || code == "0" { + return T(lang, KeyOK, nil) + } + return T(lang, "err."+code, nil) +} + +// Notify returns title and body for a notification type. Missing ".body" falls back to the type key. +func Notify(lang Lang, typ string, args map[string]string) (title, body string) { + title = T(lang, typ+".title", args) + if title == typ+".title" { + title = "" + } + body = T(lang, typ+".body", args) + if body == typ+".body" { + body = T(lang, typ, args) + if body == typ { + body = "" + } + } + return title, body +} diff --git a/internal/pkg/ratelimit/ratelimit.go b/internal/pkg/ratelimit/ratelimit.go new file mode 100644 index 0000000..f37850e --- /dev/null +++ b/internal/pkg/ratelimit/ratelimit.go @@ -0,0 +1,69 @@ +package ratelimit + +import ( + "sync" + "time" +) + +// Limiter is a per-key token bucket. Keys are RealIP (C6), never gnet RemoteAddr. +type Limiter struct { + mu sync.Mutex + rate float64 + burst float64 + buckets map[string]*bucket +} + +type bucket struct { + tokens float64 + last time.Time +} + +func New(perSec int) *Limiter { + if perSec < 1 { + perSec = 5 + } + return &Limiter{ + rate: float64(perSec), + burst: float64(perSec), + buckets: make(map[string]*bucket), + } +} + +func (l *Limiter) Allow(key string) bool { + if l == nil { + return true + } + if key == "" { + key = "unknown" + } + now := time.Now() + l.mu.Lock() + defer l.mu.Unlock() + b := l.buckets[key] + if b == nil { + b = &bucket{tokens: l.burst, last: now} + l.buckets[key] = b + } + elapsed := now.Sub(b.last).Seconds() + b.tokens += elapsed * l.rate + if b.tokens > l.burst { + b.tokens = l.burst + } + b.last = now + if b.tokens < 1 { + return false + } + b.tokens-- + if len(l.buckets) > 8192 { + l.gc(now) + } + return true +} + +func (l *Limiter) gc(now time.Time) { + for k, b := range l.buckets { + if now.Sub(b.last) > time.Minute { + delete(l.buckets, k) + } + } +} diff --git a/internal/pkg/ratelimit/ratelimit_test.go b/internal/pkg/ratelimit/ratelimit_test.go new file mode 100644 index 0000000..3baed99 --- /dev/null +++ b/internal/pkg/ratelimit/ratelimit_test.go @@ -0,0 +1,19 @@ +package ratelimit + +import "testing" + +func TestAllowBurstThenBlock(t *testing.T) { + l := New(2) + if !l.Allow("203.0.113.10") { + t.Fatal("first") + } + if !l.Allow("203.0.113.10") { + t.Fatal("burst") + } + if l.Allow("203.0.113.10") { + t.Fatal("over burst") + } + if !l.Allow("198.51.100.20") { + t.Fatal("other ip") + } +} diff --git a/internal/pkg/reply/reply.go b/internal/pkg/reply/reply.go new file mode 100644 index 0000000..2bd46bf --- /dev/null +++ b/internal/pkg/reply/reply.go @@ -0,0 +1,96 @@ +package reply + +import ( + "context" + "encoding/json" + + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" +) + +// Envelope is the unified HTTP and TCP response body: {"code":"","msg":"","data":{}}. +type Envelope struct { + Code string `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +func OK(data any) Envelope { + return Envelope{Code: errs.OK.Code, Msg: errs.OK.Msg, Data: marshalData(data)} +} + +func Fail(code, msg string) Envelope { + if code == "" { + code = errs.ErrInner.Code + } + if msg == "" { + msg = errs.ErrInner.Msg + } + return Envelope{Code: code, Msg: msg, Data: json.RawMessage("{}")} +} + +func FailData(code, msg string, data any) Envelope { + env := Fail(code, msg) + env.Data = marshalData(data) + return env +} + +func FromError(err error) Envelope { + return FromErrorLang(i18n.Default, err) +} + +func FromContext(ctx context.Context, err error) Envelope { + return FromErrorLang(reqctx.Lang(ctx), err) +} + +func FromErrorLang(lang i18n.Lang, err error) Envelope { + if err == nil { + return OK(nil) + } + if app, ok := errs.AsAppError(err); ok { + return Fail(app.Code, publicMsg(lang, app)) + } + return Fail(errs.ErrInner.Code, i18n.ErrorMsg(lang, errs.ErrInner.Code)) +} + +func publicMsg(lang i18n.Lang, app *errs.AppError) string { + if app.CustomMsg() { + return app.Msg + } + return i18n.ErrorMsg(lang, app.Code) +} + +func Marshal(env Envelope) ([]byte, error) { + return json.Marshal(env) +} + +func MustMarshal(env Envelope) []byte { + b, err := Marshal(env) + if err != nil { + b, _ = json.Marshal(Fail(errs.ErrInner.Code, errs.ErrInner.Msg)) + } + return b +} + +func Unmarshal(body []byte) (Envelope, error) { + var env Envelope + if err := json.Unmarshal(body, &env); err != nil { + return Envelope{}, errs.ErrInvalidFrame.WithInnerMsg(err.Error()) + } + if len(env.Data) == 0 { + env.Data = json.RawMessage("{}") + } + return env, nil +} + +func marshalData(data any) json.RawMessage { + if data == nil { + return json.RawMessage("{}") + } + b, err := json.Marshal(data) + if err != nil || len(b) == 0 || string(b) == "null" { + return json.RawMessage("{}") + } + return b +} diff --git a/internal/pkg/reply/reply_test.go b/internal/pkg/reply/reply_test.go new file mode 100644 index 0000000..0ae23e2 --- /dev/null +++ b/internal/pkg/reply/reply_test.go @@ -0,0 +1,46 @@ +package reply + +import ( + "encoding/json" + "testing" + + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" +) + +func TestOKAndFailEnvelope(t *testing.T) { + raw := MustMarshal(OK(map[string]int{"n": 1})) + var env Envelope + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatal(err) + } + if env.Code != "0" || env.Msg != "ok" || string(env.Data) != `{"n":1}` { + t.Fatalf("%+v", env) + } + detail := FailData(errs.ErrAvailable.Code, "unavailable", map[string]string{"mysql": "down"}) + if string(detail.Data) != `{"mysql":"down"}` { + t.Fatalf("data %s", detail.Data) + } + fail := FromError(errs.ErrNotAuthenticated.WithMsg("login first")) + if fail.Code != errs.ErrNotAuthenticated.Code || fail.Msg != "login first" || string(fail.Data) != "{}" { + t.Fatalf("fail %+v", fail) + } + zh := FromError(errs.ErrUnauthorized) + if zh.Msg != "未授权" { + t.Fatalf("zh %q", zh.Msg) + } + en := FromErrorLang(i18n.En, errs.ErrUnauthorized) + if en.Msg != "unauthorized" { + t.Fatalf("en %q", en.Msg) + } +} + +func TestUnmarshal(t *testing.T) { + env, err := Unmarshal([]byte(`{"code":"0","msg":"ok"}`)) + if err != nil { + t.Fatal(err) + } + if string(env.Data) != "{}" { + t.Fatalf("empty data %s", env.Data) + } +} diff --git a/internal/pkg/reqctx/reqctx.go b/internal/pkg/reqctx/reqctx.go index b35a924..df59c5f 100644 --- a/internal/pkg/reqctx/reqctx.go +++ b/internal/pkg/reqctx/reqctx.go @@ -1,12 +1,22 @@ package reqctx -import "context" +import ( + "context" + "crypto/rand" + "encoding/hex" + "net" + "strings" + "time" + + "spark-server/internal/pkg/i18n" +) type ctxKey struct{} // RequestContext is the per-request identity. Use RealIP for rate limit and audit, never 127.0.0.1 from gnet.RemoteAddr. type RequestContext struct { TraceID string + RequestID string UserID int64 DeviceID uint64 Language string @@ -14,6 +24,8 @@ type RequestContext struct { ConnectionID string RemoteAddr string RealIP string + JTI string + TokenExpires time.Time } func With(ctx context.Context, rc *RequestContext) context.Context { @@ -24,3 +36,59 @@ func From(ctx context.Context) *RequestContext { rc, _ := ctx.Value(ctxKey{}).(*RequestContext) return rc } + +// RealIP returns the client IP from context (PROXY / X-Real-IP), not gnet RemoteAddr. +func RealIP(ctx context.Context) string { + if rc := From(ctx); rc != nil { + return rc.RealIP + } + return "" +} + +// Lang is the canonical UI language (zh-CN / en / zh-TW) for errors and notifications. +func Lang(ctx context.Context) i18n.Lang { + if rc := From(ctx); rc != nil { + return i18n.Parse(rc.Language) + } + return i18n.Default +} + +// Language returns the canonical language tag stored on context. +func Language(ctx context.Context) string { + return Lang(ctx).String() +} + +// ParseHTTPRealIP prefers X-Real-IP, then X-Forwarded-For, then RemoteAddr host. +func ParseHTTPRealIP(realIPHeader, forwardedFor, remoteAddr string) string { + if ip := strings.TrimSpace(realIPHeader); ip != "" { + return stripHost(ip) + } + if forwardedFor != "" { + first, _, _ := strings.Cut(forwardedFor, ",") + if ip := stripHost(strings.TrimSpace(first)); ip != "" { + return ip + } + } + return stripHost(remoteAddr) +} + +func stripHost(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + host, _, err := net.SplitHostPort(v) + if err == nil { + return host + } + return v +} + +// NewID returns a 16-byte hex id for trace_id / request_id. +func NewID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "00000000000000000000000000000000" + } + return hex.EncodeToString(b[:]) +} diff --git a/internal/pkg/reqctx/reqctx_test.go b/internal/pkg/reqctx/reqctx_test.go new file mode 100644 index 0000000..f74ee7a --- /dev/null +++ b/internal/pkg/reqctx/reqctx_test.go @@ -0,0 +1,38 @@ +package reqctx + +import "testing" + +func TestParseHTTPRealIP(t *testing.T) { + got := ParseHTTPRealIP(" 203.0.113.10 ", "198.51.100.1, 192.0.2.1", "192.0.2.1:1234") + if got != "203.0.113.10" { + t.Fatalf("x-real-ip %q", got) + } + got = ParseHTTPRealIP("", "198.51.100.1, 192.0.2.1", "192.0.2.1:1234") + if got != "198.51.100.1" { + t.Fatalf("xff %q", got) + } + got = ParseHTTPRealIP("", "", "192.0.2.1:1234") + if got != "192.0.2.1" { + t.Fatalf("remote %q", got) + } +} + +func TestRealIPFromContext(t *testing.T) { + ctx := With(t.Context(), &RequestContext{RealIP: "203.0.113.10"}) + if RealIP(ctx) != "203.0.113.10" { + t.Fatalf("got %q", RealIP(ctx)) + } + if RealIP(t.Context()) != "" { + t.Fatal("empty ctx") + } +} + +func TestLangFromContext(t *testing.T) { + ctx := With(t.Context(), &RequestContext{Language: "en-US"}) + if Language(ctx) != "en" { + t.Fatalf("got %q", Language(ctx)) + } + if Language(t.Context()) != "zh-CN" { + t.Fatalf("default %q", Language(t.Context())) + } +} diff --git a/internal/pkg/validate/validate.go b/internal/pkg/validate/validate.go new file mode 100644 index 0000000..d6d0869 --- /dev/null +++ b/internal/pkg/validate/validate.go @@ -0,0 +1,110 @@ +package validate + +import ( + "errors" + "reflect" + "strings" + "sync" + + "github.com/go-playground/validator/v10" + + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" +) + +var ( + once sync.Once + v *validator.Validate +) + +func engine() *validator.Validate { + once.Do(func() { + v = validator.New(validator.WithRequiredStructEnabled()) + v.RegisterTagNameFunc(func(f reflect.StructField) string { + if l := f.Tag.Get("label"); l != "" { + return l + } + name, _, _ := strings.Cut(f.Tag.Get("json"), ",") + if name != "" && name != "-" { + return name + } + return f.Name + }) + }) + return v +} + +// Struct validates v in zh-CN. Prefer StructLang with the request language. +func Struct(val any) error { + return StructLang(i18n.Default, val) +} + +// StructLang validates v. `label` / `errmsg` are i18n keys (literal text still works as fallback). +// errmsg example: `errmsg:"required:validate.login.phone_required"` +func StructLang(lang i18n.Lang, val any) error { + if val == nil { + return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyValidateFailed, nil)) + } + err := engine().Struct(val) + if err == nil { + return nil + } + var ves validator.ValidationErrors + if !errors.As(err, &ves) { + return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyValidateFailed, nil)) + } + msgs := make([]string, 0, len(ves)) + for _, fe := range ves { + msgs = append(msgs, fieldMessage(lang, val, fe)) + } + return errs.ErrInvalidArgument.WithMsg(strings.Join(msgs, "; ")) +} + +func fieldMessage(lang i18n.Lang, root any, fe validator.FieldError) string { + label := i18n.T(lang, fe.Field(), nil) + if custom := errmsgFor(root, fe.StructField(), fe.Tag()); custom != "" { + return i18n.T(lang, custom, map[string]string{"label": label, "param": fe.Param()}) + } + return defaultMessage(lang, label, fe) +} + +func errmsgFor(root any, structField, tag string) string { + sf, ok := findField(reflect.TypeOf(root), structField) + if !ok { + return "" + } + raw := sf.Tag.Get("errmsg") + if raw == "" { + return "" + } + for _, part := range strings.Split(raw, ";") { + k, v, found := strings.Cut(strings.TrimSpace(part), ":") + if found && k == tag { + return v + } + } + return "" +} + +func findField(t reflect.Type, name string) (reflect.StructField, bool) { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return reflect.StructField{}, false + } + sf, ok := t.FieldByName(name) + return sf, ok +} + +func defaultMessage(lang i18n.Lang, label string, fe validator.FieldError) string { + key := "validate." + fe.Tag() + if lookupEmpty(lang, key) { + key = i18n.KeyValidateDefault + } + return i18n.T(lang, key, map[string]string{"label": label, "param": fe.Param()}) +} + +func lookupEmpty(lang i18n.Lang, key string) bool { + return i18n.T(lang, key, nil) == key +} diff --git a/internal/pkg/validate/validate_test.go b/internal/pkg/validate/validate_test.go new file mode 100644 index 0000000..22c05c8 --- /dev/null +++ b/internal/pkg/validate/validate_test.go @@ -0,0 +1,50 @@ +package validate + +import ( + "strings" + "testing" + + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" +) + +type sample struct { + Phone string `json:"phone" validate:"required" label:"field.phone" errmsg:"required:validate.login.phone_required"` + Password string `json:"password" validate:"required,min=6" label:"field.password"` + DeviceID uint64 `json:"device_id" validate:"required" label:"field.device_id"` +} + +func TestStructCustomAndDefaultMessages(t *testing.T) { + err := Struct(sample{}) + app, ok := errs.AsAppError(err) + if !ok { + t.Fatalf("type %T", err) + } + if app.Code != errs.ErrInvalidArgument.Code { + t.Fatalf("code %s", app.Code) + } + if !strings.Contains(app.Msg, "请填写手机号") || !strings.Contains(app.Msg, "密码不能为空") || !strings.Contains(app.Msg, "设备ID不能为空") { + t.Fatalf("msg %s", app.Msg) + } + err = Struct(sample{Phone: "1", Password: "123", DeviceID: 1}) + app, ok = errs.AsAppError(err) + if !ok || !strings.Contains(app.Msg, "密码不能少于6") { + t.Fatalf("min %v", err) + } + if err := Struct(sample{Phone: "1", Password: "123456", DeviceID: 1}); err != nil { + t.Fatal(err) + } +} + +func TestStructLangEnglish(t *testing.T) { + err := StructLang(i18n.En, sample{}) + app, ok := errs.AsAppError(err) + if !ok || !strings.Contains(app.Msg, "Please enter your phone number") || !strings.Contains(app.Msg, "Password is required") { + t.Fatalf("en %v", err) + } + err = StructLang(i18n.ZhTW, sample{Phone: "1", Password: "123", DeviceID: 1}) + app, ok = errs.AsAppError(err) + if !ok || !strings.Contains(app.Msg, "密碼不能少於6") { + t.Fatalf("zh-TW %v", err) + } +} diff --git a/internal/transport/http/auth.go b/internal/transport/http/auth.go new file mode 100644 index 0000000..e609b2c --- /dev/null +++ b/internal/transport/http/auth.go @@ -0,0 +1,115 @@ +package httpx + +import ( + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + authlogic "spark-server/internal/module/auth/logic" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/ratelimit" + "spark-server/internal/pkg/reqctx" +) + +const ( + headerAuthorization = "Authorization" + headerDeviceID = "X-Device-ID" + headerAppVersion = "X-App-Version" + headerLanguage = "Accept-Language" + headerXLanguage = "X-Language" + headerRealIP = "X-Real-IP" + headerForwardedFor = "X-Forwarded-For" + bearerPrefix = "bearer " +) + +func Auth(svc authlogic.Service) gin.HandlerFunc { + return func(c *gin.Context) { + if svc == nil || publicPath(c.Request.URL.Path) { + c.Next() + return + } + token, ok := bearerToken(c.GetHeader(headerAuthorization)) + if !ok { + abortAuth(c, errs.ErrUnauthorized) + return + } + principal, err := svc.Authenticate(c.Request.Context(), token) + if err != nil { + abortAuth(c, err) + return + } + if err := matchDeviceHeader(c.GetHeader(headerDeviceID), principal.DeviceID); err != nil { + abortAuth(c, err) + return + } + rc := reqctx.From(c.Request.Context()) + if rc == nil { + rc = &reqctx.RequestContext{} + c.Request = c.Request.WithContext(reqctx.With(c.Request.Context(), rc)) + } + rc.UserID = principal.UserID + rc.DeviceID = principal.DeviceID + rc.JTI = principal.JTI + rc.TokenExpires = principal.ExpiresAt + c.Next() + } +} + +func AuthRateLimit(l *ratelimit.Limiter) gin.HandlerFunc { + return func(c *gin.Context) { + if l == nil || !authLimitPath(c.Request.URL.Path) { + c.Next() + return + } + ip := reqctx.RealIP(c.Request.Context()) + if !l.Allow(ip) { + c.Abort() + JSONError(c, errs.ErrRateLimited) + return + } + c.Next() + } +} + +func authLimitPath(path string) bool { + switch path { + case "/v1/auth/login", "/v1/auth/email-codes", "/v1/auth/token/refresh": + return true + default: + return false + } +} + +func publicPath(path string) bool { + switch path { + case "/healthz", "/readyz", "/openapi.yaml", "/docs", "/docs/", "/v1/auth/login", "/v1/auth/email-codes", "/v1/auth/token/refresh": + return true + default: + return strings.HasPrefix(path, "/docs/") + } +} + +func bearerToken(h string) (string, bool) { + if len(h) < len(bearerPrefix) || !strings.EqualFold(h[:len(bearerPrefix)], bearerPrefix) { + return "", false + } + tok := strings.TrimSpace(h[len(bearerPrefix):]) + return tok, tok != "" +} + +func matchDeviceHeader(raw string, want uint64) error { + if raw == "" { + return nil + } + got, err := strconv.ParseUint(raw, 10, 64) + if err != nil || got != want { + return errs.ErrUnauthorized + } + return nil +} + +func abortAuth(c *gin.Context, err error) { + c.Abort() + JSONError(c, err) +} diff --git a/internal/transport/http/docs.go b/internal/transport/http/docs.go new file mode 100644 index 0000000..714abba --- /dev/null +++ b/internal/transport/http/docs.go @@ -0,0 +1,19 @@ +package httpx + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func registerDocs(r *gin.Engine) { + r.GET("/openapi.yaml", func(c *gin.Context) { + c.Data(http.StatusOK, "application/yaml; charset=utf-8", openAPISpec) + }) + r.GET("/docs", func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", docsHTML) + }) + r.GET("/docs/", func(c *gin.Context) { + c.Redirect(http.StatusFound, "/docs") + }) +} diff --git a/internal/transport/http/docs/index.html b/internal/transport/http/docs/index.html new file mode 100644 index 0000000..69b057f --- /dev/null +++ b/internal/transport/http/docs/index.html @@ -0,0 +1,26 @@ + + + + + Spark IM API + + + + +
+ + + + + diff --git a/internal/transport/http/docs/openapi.yaml b/internal/transport/http/docs/openapi.yaml new file mode 100644 index 0000000..cac2446 --- /dev/null +++ b/internal/transport/http/docs/openapi.yaml @@ -0,0 +1,1154 @@ +openapi: 3.0.3 +info: + title: Spark IM API + version: 0.1.0 + description: | + 本页是 **HTTP REST**(Gin `127.0.0.1:8080`)。长连接 IM 是另一套 TCP,见文末。 + + ## 响应 + + ```json + {"code":"0","msg":"ok","data":{}} + ``` + + `code` 是字符串。`"0"` 成功,其它失败。失败时 `data` 为 `{}`。看 `code`,不要只看 HTTP 状态(多数接口 HTTP 200;`/readyz` 未就绪才是 503)。 + + ## 鉴权 + + 1. `POST /v1/auth/login` 拿 `access_token` / `refresh_token`。测试号 `test01@spark.com`~`test10@spark.com`,`verify_code` 任意。 + 2. 其它接口:`Authorization: Bearer `。 + 3. 可选 `X-Device-ID` 必须等于登录时的 `device_id`,否则 `40101`。 + 4. 续期 `POST /v1/auth/token/refresh`(用 refresh,不必带 Bearer)。退出 `POST /v1/auth/logout`。 + + 语言:`X-Language` 优先,否则 `Accept-Language`。取值 `zh-CN`(默认)/ `en` / `zh-TW`。 + + ## TCP(不是 Protobuf) + + 二进制 Frame 头 23 字节(Magic `SP` + Command + SequenceID + BodyLength),**Body 是上面同一套 JSON**。没有 `.proto`,不要按 Protobuf 解包。 + 公网 TLS 在 Nginx `:443` 终结;gnet 只收本机 PROXY 明文。心跳 / AUTH / GENERATE_MSG_ID 已通;发消息与同步还在接。 +servers: + - url: http://127.0.0.1:8080 + description: loopback HTTP (not public) +security: + - bearerAuth: [] +tags: + - name: system + - name: auth + - name: users + - name: friends + - name: groups + - name: conversations + - name: messages + - name: files + - name: favorites +paths: + /healthz: + get: + tags: [system] + security: [] + summary: 进程探活 + operationId: healthz + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + example: + code: "0" + msg: ok + data: + status: ok + /readyz: + get: + tags: [system] + security: [] + summary: 就绪探活(MySQL / Redis / 是否接流量) + description: | + 进程存活用 `/healthz`(始终 200)。 + `/readyz` 在未接流量、依赖不可用或关停中返回 HTTP 503,`code=50301`。 + Nginx / 编排应摘流看本接口,不要看 `/healthz`。 + operationId: readyz + responses: + "200": + description: 已就绪 + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + example: + code: "0" + msg: ok + data: + status: ok + accepting: true + mysql: ok + redis: ok + taskpool: ok + "503": + description: 未就绪 + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + example: + code: "50301" + msg: 服务不可用 + data: + status: not_ready + accepting: false + mysql: skipped + redis: skipped + taskpool: ok + /docs: + get: + tags: [system] + security: [] + summary: OpenAPI 文档页(Swagger UI) + operationId: docsUI + responses: + "200": + description: HTML + content: + text/html: + schema: + type: string + /openapi.yaml: + get: + tags: [system] + security: [] + summary: OpenAPI 规范原文 + operationId: openAPISpec + responses: + "200": + description: YAML + content: + application/yaml: + schema: + type: string + /v1/auth/email-codes: + post: + tags: [auth] + security: [] + summary: 发送邮箱登录验证码 + description: | + 验证码 5 分钟有效,同一邮箱 60 秒内只能发一次。 + 测试账号 `test01@spark.com`~`test10@spark.com` 无需发码,登录时任意填写 `verify_code`。 + 当前不发送真实邮件,验证码写入 Redis(无 Redis 时为进程内存)。 + operationId: sendEmailCode + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SendEmailCodeRequest" + example: + email: ada@example.com + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + examples: + ok: + value: + code: "0" + msg: ok + data: + expires_in: 300 + /v1/auth/login: + post: + tags: [auth] + security: [] + summary: 邮箱验证码登录并签发 JWT + description: | + 首次验证成功会创建用户(Snowflake uid、默认 spark_id、shortid 个人二维码票据,不含 uid)。 + 登录写入/更新 `user_devices`(device_type 存在 `platform` 列、device_name、app_version、language、last_ip),并追加 `user_login_logs`。 + 返回 access_token 与 refresh_token。测试账号任意验证码即可。 + `device_type` 必填:ios / android / harmony / windows / macos / web(也接受旧字段 `platform`)。 + operationId: login + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + example: + email: test01@spark.com + verify_code: "000000" + device_id: 7 + device_type: ios + device_name: iPhone + responses: + "200": + description: 统一 envelope。成功时 data 为 LoginData;失败时 data 为 {} + content: + application/json: + schema: + $ref: "#/components/schemas/LoginEnvelope" + examples: + ok: + value: + code: "0" + msg: ok + data: + access_token: eyJhbGciOiJIUzI1NiJ9... + refresh_token: eyJhbGciOiJIUzI1NiJ9... + expires_in: 604800 + refresh_expires_in: 2592000 + user_id: 1940000000000000000 + device_id: 7 + spark_id: s1a2b3c + unauthorized: + value: + code: "40101" + msg: 未授权 + data: {} + invalid: + value: + code: "40001" + msg: 请填写验证码 + data: {} + /v1/auth/token/refresh: + post: + tags: [auth] + security: [] + summary: 用 refresh_token 续期 + description: | + 校验 refresh_token 后签发新的 access/refresh,旧一对立即失效(jti denylist)。 + 不需要 access Bearer。按 RealIP 限流。 + operationId: refreshToken + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RefreshRequest" + example: + refresh_token: eyJhbGciOiJIUzI1NiJ9... + responses: + "200": + description: 与登录相同的 LoginData + content: + application/json: + schema: + $ref: "#/components/schemas/LoginEnvelope" + /v1/auth/logout: + post: + tags: [auth] + summary: 退出当前设备 + description: 吊销该设备的 access/refresh,并踢掉同 device_id 的 TCP 连接。 + operationId: logout + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/users/me: + get: + tags: [users] + summary: 当前用户资料 + operationId: getMe + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + example: + code: "0" + msg: ok + data: + uid: 1940000000000000000 + spark_id: alice_ok + nickname: test01 + avatar: perm/avatar/1940000000000000000/20260917/1940000000000000001.png + qrcode: "-NDveu-9Q" + birthday: "" + gender: 0 + status: 1 + last_login_time: 1726550000000 + last_active_time: 1726550000000 + create_time: 1726550000000 + update_time: 1726550000000 + patch: + tags: [users] + summary: 修改资料 + description: 可改 spark_id、nickname、avatar、gender(0/1/2)、birthday(YYYY-MM-DD)。未出现的字段不改。 + operationId: patchMe + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PatchMeRequest" + example: + nickname: Ada + gender: 2 + birthday: "1994-05-01" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/friends/lookup: + get: + tags: [friends] + summary: 按邮箱 / Spark账号 / 二维码查找用户 + description: | + 查询参数 `email`、`spark_id`、`qrcode`、`uid` 四选一。 + 二维码为 shortid 个人扫描票据,不含 uid。对方已拉黑当前用户时返回 40401。 + operationId: lookupFriend + parameters: + - in: query + name: email + schema: { type: string, format: email } + - in: query + name: spark_id + schema: { type: string } + - in: query + name: qrcode + schema: { type: string, example: "-NDveu-9Q" } + - in: query + name: uid + schema: { type: integer, format: int64 } + responses: + "200": + description: 统一 envelope,data 为好友卡片(含 relation:0无 1好友 2已拉黑) + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/friends/requests: + get: + tags: [friends] + summary: 收到的待处理好友申请 + operationId: listFriendRequests + parameters: + - in: query + name: limit + schema: { type: integer, default: 20, maximum: 100 } + responses: + "200": + description: data.items + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + post: + tags: [friends] + summary: 发起好友申请 + description: | + 通过扫描二维码、搜索邮箱或 spark_id 发起。可填申请理由与备注。 + 同一对用户只保留一条申请,重复发起覆盖理由/备注,不新增行。 + 若对方已有待处理申请指向自己,则自动成为双向好友并写入一条系统消息。 + 不支持单向好友。 + operationId: applyFriend + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/FriendApplyRequest" + example: + email: test02@spark.com + reason: 我是同事 + remark: 同事 + responses: + "200": + description: data.status 为 pending 或 accepted + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/friends: + get: + tags: [friends] + summary: 好友列表 + operationId: listFriends + parameters: + - in: query + name: limit + schema: { type: integer, default: 20, maximum: 100 } + responses: + "200": + description: data.items + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + post: + tags: [friends] + summary: 同意好友申请 + description: 成为双向好友后自动在单聊中插入一条 `kind=friend.became` 系统消息并入队 deliver。 + operationId: acceptFriend + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/FriendAcceptRequest" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/friends/{uid}: + get: + tags: [friends] + summary: 查看好友资料 + operationId: getFriend + parameters: + - $ref: "#/components/parameters/FriendUID" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + delete: + tags: [friends] + summary: 删除好友 + description: 解除双向关系,双方都不再是好友。 + operationId: deleteFriend + parameters: + - $ref: "#/components/parameters/FriendUID" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/friends/{uid}/remark: + put: + tags: [friends] + summary: 设置好友备注 + operationId: setFriendRemark + parameters: + - $ref: "#/components/parameters/FriendUID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/FriendRemarkRequest" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/friends/{uid}/block: + put: + tags: [friends] + summary: 拉黑 + description: 单向拉黑并解除好友关系。被拉黑一方再申请时视为用户不存在。 + operationId: blockFriend + parameters: + - $ref: "#/components/parameters/FriendUID" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + delete: + tags: [friends] + summary: 取消拉黑 + operationId: unblockFriend + parameters: + - $ref: "#/components/parameters/FriendUID" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/friends/{uid}/reports: + post: + tags: [friends] + summary: 投诉好友 + operationId: reportFriend + parameters: + - $ref: "#/components/parameters/FriendUID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/FriendReportRequest" + example: + reason: 1 + detail: spam + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/groups: + get: + tags: [groups] + summary: 我加入的群 + operationId: listGroups + parameters: + - in: query + name: limit + schema: { type: integer, default: 20, maximum: 100 } + responses: + "200": + description: data.items,id 为自增群号,conversation_id 为 Snowflake 会话 id + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + post: + tags: [groups] + summary: 创建群 + description: | + `id` 为表内自增群号。发消息、同步使用返回的 `conversation_id`(Snowflake)。 + 只能拉好友入群。群主无法直接退群,需先转让。 + 建群成功后服务端按实际人数拼接成员已有头像(注册默认或自定义),最多 9 人;两人就是并排两格,不会凑九宫格。 + 成员头像拉不到时用字母占位图,整张拼图失败则改用群名默认头像,`avatar` 不会为空。 + 写入 `perm/group/{群号}/`。之后可用 `purpose=group_avatar` 直传自定义头像再 PATCH。 + operationId: createGroup + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateGroupRequest" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/groups/{id}: + get: + tags: [groups] + summary: 群资料 + operationId: getGroup + parameters: + - $ref: "#/components/parameters/GroupID" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + patch: + tags: [groups] + summary: 修改群名称 / 头像 / 全员禁言 + operationId: updateGroup + parameters: + - $ref: "#/components/parameters/GroupID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateGroupRequest" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + delete: + tags: [groups] + summary: 解散群(仅群主) + operationId: dissolveGroup + parameters: + - $ref: "#/components/parameters/GroupID" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/groups/{id}/members: + get: + tags: [groups] + summary: 群成员 + operationId: listGroupMembers + parameters: + - $ref: "#/components/parameters/GroupID" + - in: query + name: limit + schema: { type: integer, default: 200, maximum: 200 } + responses: + "200": + description: data.items + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + post: + tags: [groups] + summary: 邀请好友入群 + operationId: inviteGroupMembers + parameters: + - $ref: "#/components/parameters/GroupID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/InviteGroupMembersRequest" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/groups/{id}/members/{uid}: + patch: + tags: [groups] + summary: 改成员角色 / 禁言 / 群昵称 + operationId: updateGroupMember + parameters: + - $ref: "#/components/parameters/GroupID" + - $ref: "#/components/parameters/FriendUID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateGroupMemberRequest" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + delete: + tags: [groups] + summary: 踢人,或 uid 为自己时退群 + operationId: removeGroupMember + parameters: + - $ref: "#/components/parameters/GroupID" + - $ref: "#/components/parameters/FriendUID" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/groups/{id}/transfer: + post: + tags: [groups] + summary: 转让群主 + operationId: transferGroup + parameters: + - $ref: "#/components/parameters/GroupID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TransferGroupRequest" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/files: + post: + tags: [files] + summary: 签发对象存储直传凭证 + description: | + 客户端只传 `purpose` / `mime` / `size`,服务端生成 object_key 并签发短时 Presigned PUT。 + 文件直传 RustFS,不经过 Gin。 + `avatar` 写入 `perm/avatar/{uid}/…`(不过期);`group_avatar` 写入 `perm/group/{群号}/…`(需 `group_id`,群主/管理员); + 聊天媒体写入 `chat/{yyyyMMdd}/{uid}/…`(桶生命周期过期)。 + 客户端 PUT 时必须带返回的 `headers`(至少 `Content-Type`)。消息与头像只存 `object_key`。 + operationId: createUploadCredential + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUploadRequest" + example: + purpose: chat_image + mime: image/jpeg + size: 245760 + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + example: + code: "0" + msg: ok + data: + object_key: chat/20260917/1/2.jpg + upload_url: http://47.236.66.9:9000/spark/chat/20260917/1/2.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256 + method: PUT + headers: + Content-Type: image/jpeg + expires_in: 900 + /v1/files/downloads: + post: + tags: [files] + summary: 签发对象存储下载凭证 + description: 已登录用户用 object_key 换取短时 Presigned GET。key 必须是 `perm/` 或 `chat/` 前缀。 + operationId: createDownloadCredential + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateDownloadRequest" + example: + object_key: chat/20260917/1/2.jpg + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/favorites: + get: + tags: [favorites] + summary: 收藏列表 + operationId: listFavorites + parameters: + - in: query + name: limit + schema: + type: integer + default: 20 + maximum: 100 + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + post: + tags: [favorites] + summary: 收藏一条消息 + description: | + 会话成员可将消息收入收藏。图片/语音/视频/文件会 CopyObject 到 `perm/fav/{uid}/…`。 + 源对象已从 `chat/` 过期删除则失败(`code=40402`),不提供本地补传。 + 同一 `msg_id` 重复收藏返回已有记录。撤回不影响已收藏快照。 + operationId: createFavorite + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateFavoriteRequest" + example: + msg_id: 1 + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/favorites/{id}: + delete: + tags: [favorites] + summary: 取消收藏 + operationId: deleteFavorite + parameters: + - $ref: "#/components/parameters/FavoriteID" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: Access JWT from /v1/auth/login. Same token is used for TCP AUTH. + schemas: + Envelope: + type: object + required: [code, msg, data] + properties: + code: + type: string + description: 业务码,成功为 "0" + example: "0" + msg: + type: string + example: ok + data: + type: object + additionalProperties: true + SendEmailCodeRequest: + type: object + required: [email] + properties: + email: + type: string + format: email + LoginRequest: + type: object + required: [email, verify_code, device_id] + properties: + email: + type: string + format: email + verify_code: + type: string + description: 邮箱验证码。测试账号 test01@spark.com~test10@spark.com 可任意填写。 + device_id: + type: integer + format: int64 + minimum: 1 + device_type: + type: string + enum: [ios, android, harmony, windows, macos, web] + platform: + type: string + description: device_type 的兼容字段 + enum: [ios, android, harmony, windows, macos, web] + device_name: + type: string + maxLength: 128 + LoginData: + type: object + required: [access_token, refresh_token, expires_in, refresh_expires_in, user_id, device_id, spark_id] + properties: + access_token: + type: string + refresh_token: + type: string + expires_in: + type: integer + format: int64 + description: access TTL in seconds (default 604800 = 7 days) + example: 604800 + refresh_expires_in: + type: integer + format: int64 + description: refresh TTL in seconds (default 2592000 = 30 days) + example: 2592000 + user_id: + type: integer + format: int64 + description: Snowflake user id + device_id: + type: integer + format: int64 + spark_id: + type: string + description: Public handle, unique and user-changeable + RefreshRequest: + type: object + required: [refresh_token] + properties: + refresh_token: + type: string + PatchMeRequest: + type: object + properties: + spark_id: + type: string + minLength: 6 + maxLength: 32 + pattern: "^[a-zA-Z][a-zA-Z0-9_]{5,31}$" + nickname: + type: string + minLength: 1 + maxLength: 64 + avatar: + type: string + maxLength: 512 + gender: + type: integer + enum: [0, 1, 2] + birthday: + type: string + description: YYYY-MM-DD;空字符串表示清除 + example: "1994-05-01" + FriendApplyRequest: + type: object + properties: + uid: + type: integer + format: int64 + email: + type: string + format: email + spark_id: + type: string + qrcode: + type: string + description: 个人二维码扫描票据(shortid),不含 uid + reason: + type: string + maxLength: 128 + remark: + type: string + maxLength: 64 + FriendAcceptRequest: + type: object + required: [request_id] + properties: + request_id: + type: integer + format: int64 + remark: + type: string + maxLength: 64 + FriendRemarkRequest: + type: object + properties: + remark: + type: string + maxLength: 64 + FriendReportRequest: + type: object + required: [reason] + properties: + reason: + type: integer + enum: [1, 2, 3, 4] + description: 1=垃圾 2=骚扰 3=不实身份 4=其他 + detail: + type: string + maxLength: 512 + CreateUploadRequest: + type: object + required: [purpose, mime, size] + properties: + purpose: + type: string + enum: [avatar, group_avatar, chat_image, chat_voice, chat_video, chat_gif, chat_file] + mime: + type: string + example: image/jpeg + size: + type: integer + format: int64 + minimum: 1 + group_id: + type: integer + format: int64 + minimum: 1 + description: purpose=group_avatar 时必填,为自增群号 + CreateDownloadRequest: + type: object + required: [object_key] + properties: + object_key: + type: string + maxLength: 512 + CreateFavoriteRequest: + type: object + required: [msg_id] + properties: + msg_id: + type: integer + format: int64 + minimum: 1 + CreateGroupRequest: + type: object + required: [name, member_uids] + properties: + name: + type: string + minLength: 1 + maxLength: 32 + member_uids: + type: array + minItems: 1 + maxItems: 199 + items: + type: integer + format: int64 + UpdateGroupRequest: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 32 + avatar: + type: string + muted_all: + type: boolean + InviteGroupMembersRequest: + type: object + required: [uids] + properties: + uids: + type: array + minItems: 1 + items: + type: integer + format: int64 + UpdateGroupMemberRequest: + type: object + properties: + role: + type: integer + enum: [2, 3] + description: 2=管理员 3=普通成员;群主只能通过转让变更 + speak_muted: + type: boolean + nickname: + type: string + maxLength: 32 + TransferGroupRequest: + type: object + required: [uid] + properties: + uid: + type: integer + format: int64 + LoginEnvelope: + type: object + required: [code, msg, data] + properties: + code: + type: string + msg: + type: string + data: + $ref: "#/components/schemas/LoginData" + AuthData: + type: object + properties: + user_id: + type: integer + format: int64 + device_id: + type: integer + format: int64 + MsgIDData: + type: object + properties: + msg_id: + type: integer + format: int64 + headers: + X-Request-ID: + schema: + type: string + X-Trace-ID: + schema: + type: string + X-Device-ID: + schema: + type: string + description: Optional. Must match JWT did when present. + X-App-Version: + schema: + type: string + Accept-Language: + schema: + type: string + enum: [zh-CN, en, zh-TW] + example: zh-CN + description: 用户语言。也可用 X-Language。缺省 zh-CN。 + X-Language: + schema: + type: string + enum: [zh-CN, en, zh-TW] + description: 覆盖 Accept-Language 的简洁语言标签。 + X-Real-IP: + schema: + type: string + description: Nginx PROXY / SNI 写入的客户端真实 IP,进入 reqctx.RealIP。 + parameters: + Authorization: + in: header + name: Authorization + required: true + schema: + type: string + description: "Bearer " + X-Device-ID: + in: header + name: X-Device-ID + required: false + schema: + type: string + X-App-Version: + in: header + name: X-App-Version + required: false + schema: + type: string + Accept-Language: + in: header + name: Accept-Language + required: false + schema: + type: string + enum: [zh-CN, en, zh-TW] + description: 用户语言。缺省 zh-CN。 + X-Language: + in: header + name: X-Language + required: false + schema: + type: string + enum: [zh-CN, en, zh-TW] + description: 覆盖 Accept-Language。 + FriendUID: + in: path + name: uid + required: true + schema: + type: integer + format: int64 + description: 对方 Snowflake uid。 + FavoriteID: + in: path + name: id + required: true + schema: + type: integer + format: int64 + description: 收藏 Snowflake id。 + GroupID: + in: path + name: id + required: true + schema: + type: integer + format: int64 + description: 自增群号(不是 conversation_id)。 diff --git a/internal/transport/http/embed.go b/internal/transport/http/embed.go new file mode 100644 index 0000000..025d5a4 --- /dev/null +++ b/internal/transport/http/embed.go @@ -0,0 +1,11 @@ +package httpx + +import ( + _ "embed" +) + +//go:embed docs/openapi.yaml +var openAPISpec []byte + +//go:embed docs/index.html +var docsHTML []byte diff --git a/internal/transport/http/file_test.go b/internal/transport/http/file_test.go new file mode 100644 index 0000000..ff79f88 --- /dev/null +++ b/internal/transport/http/file_test.go @@ -0,0 +1,115 @@ +package httpx + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "strings" + "testing" + "time" + + "go.uber.org/zap" + + "spark-server/internal/infrastructure/id" + authlogic "spark-server/internal/module/auth/logic" + convlogic "spark-server/internal/module/conversation/logic" + convmodel "spark-server/internal/module/conversation/model" + favlogic "spark-server/internal/module/favorite/logic" + favmodel "spark-server/internal/module/favorite/model" + msgentity "spark-server/internal/module/message/entity" + msgmodel "spark-server/internal/module/message/model" + uploadlogic "spark-server/internal/module/upload/logic" + userlogic "spark-server/internal/module/user/logic" + usermodel "spark-server/internal/module/user/model" + "spark-server/internal/pkg/errs" +) + +type stubPresign struct{} + +func (stubPresign) PresignPut(_ context.Context, key, _ string, _ time.Duration) (string, error) { + return "http://rustfs.test/" + key + "?sig=1", nil +} + +func (stubPresign) PresignGet(_ context.Context, key string, _ time.Duration) (string, error) { + return "http://rustfs.test/" + key + "?get=1", nil +} + +func testFileEngine(t *testing.T) (*ginEngineBundle, *id.Generator, *msgmodel.MemoryRepo, *convmodel.MemoryP2P) { + t.Helper() + j, err := authlogic.NewJWT(&authlogic.Options{Secret: "test-secret-test-secret", Issuer: "spark-im"}) + if err != nil { + t.Fatal(err) + } + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + msgs := msgmodel.NewMemoryRepo() + convs := convmodel.NewMemoryP2P() + files := uploadlogic.NewService(stubPresign{}, ids, uploadlogic.Options{ + Now: func() time.Time { return time.Date(2026, 9, 17, 0, 0, 0, 0, time.UTC) }, + }) + favs := favlogic.NewService(favmodel.NewMemoryRepo(), msgs, convs, nil, ids) + r := NewEngine(zap.NewNop(), &EngineDeps{ + Auth: j, Login: authlogic.NewLoginService(j, users), Users: users, + Files: files, Favorites: favs, + }) + return &ginEngineBundle{engine: r}, ids, msgs, convs +} + +func TestUploadCredentialHTTP(t *testing.T) { + bundle, _, _, _ := testFileEngine(t) + h := bundle.engine + tok := loginUser(t, h, "test01@spark.com", 1) + env := authJSON(t, h, &tok, http.MethodPost, "/v1/files", `{"purpose":"chat_image","mime":"image/jpeg","size":1024}`) + if env.Code != "0" { + t.Fatalf("%+v", env) + } + var cred struct { + ObjectKey string `json:"object_key"` + Method string `json:"method"` + } + if err := json.Unmarshal(env.Data, &cred); err != nil || cred.Method != "PUT" || !strings.Contains(cred.ObjectKey, "chat/20260917/") { + t.Fatalf("%s %v", env.Data, err) + } + bad := authJSON(t, h, &tok, http.MethodPost, "/v1/files", `{"purpose":"avatar","mime":"application/pdf","size":10}`) + if bad.Code != errs.ErrInvalidArgument.Code { + t.Fatalf("mime %+v", bad) + } +} + +func TestFavoriteTextHTTP(t *testing.T) { + bundle, ids, msgs, convs := testFileEngine(t) + h := bundle.engine + tok := loginUser(t, h, "test01@spark.com", 1) + p2p := convlogic.NewP2PService(convs, ids) + convID, err := p2p.EnsureP2P(t.Context(), tok.UserID, tok.UserID+1) + if err != nil { + t.Fatal(err) + } + raw, err := msgentity.NewText("keep").Marshal() + if err != nil { + t.Fatal(err) + } + msgID, err := ids.NextID() + if err != nil { + t.Fatal(err) + } + if err := msgs.Create(t.Context(), &msgentity.Message{ + ID: msgID, ConversationID: convID, SenderID: tok.UserID, ClientMsgID: "http-1", + Type: msgentity.TypeText, Content: raw, Status: msgentity.StatusNormal, + }); err != nil { + t.Fatal(err) + } + body := `{"msg_id":` + strconv.FormatInt(msgID, 10) + `}` + env := authJSON(t, h, &tok, http.MethodPost, "/v1/favorites", body) + if env.Code != "0" { + t.Fatalf("fav %+v", env) + } + list := authJSON(t, h, &tok, http.MethodGet, "/v1/favorites", "") + if list.Code != "0" || !strings.Contains(string(list.Data), `"keep"`) { + t.Fatalf("list %+v", list) + } +} diff --git a/internal/transport/http/friend_test.go b/internal/transport/http/friend_test.go new file mode 100644 index 0000000..b2ef2e2 --- /dev/null +++ b/internal/transport/http/friend_test.go @@ -0,0 +1,136 @@ +package httpx + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "go.uber.org/zap" + + "spark-server/internal/infrastructure/id" + authentity "spark-server/internal/module/auth/entity" + authlogic "spark-server/internal/module/auth/logic" + convlogic "spark-server/internal/module/conversation/logic" + convmodel "spark-server/internal/module/conversation/model" + delivermodel "spark-server/internal/module/deliver/model" + friendlogic "spark-server/internal/module/friend/logic" + friendmodel "spark-server/internal/module/friend/model" + msgmodel "spark-server/internal/module/message/model" + userlogic "spark-server/internal/module/user/logic" + usermodel "spark-server/internal/module/user/model" + "spark-server/internal/pkg/reply" +) + +func testFriendEngine(t *testing.T) *ginEngineBundle { + t.Helper() + j, err := authlogic.NewJWT(&authlogic.Options{Secret: "test-secret-test-secret", Issuer: "spark-im"}) + if err != nil { + t.Fatal(err) + } + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + chats := convlogic.NewP2PService(convmodel.NewMemoryP2P(), ids) + friends := friendlogic.NewService( + friendmodel.NewMemoryRepo(), users, ids, + friendlogic.NewGreeter(chats, msgmodel.NewMemoryRepo(), ids, delivermodel.NewMemoryQueue()), + ) + r := NewEngine(zap.NewNop(), &EngineDeps{ + Auth: j, + Login: authlogic.NewLoginService(j, users), + Users: users, + Friends: friends, + }) + return &ginEngineBundle{engine: r} +} + +type ginEngineBundle struct { + engine http.Handler +} + +func loginUser(t *testing.T, h http.Handler, email string, device uint64) authentity.IssuedToken { + t.Helper() + body := fmt.Sprintf(`{"email":%q,"verify_code":"any","device_id":%d,"device_type":"ios","device_name":"phone"}`, email, device) + req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + var env reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatal(err) + } + if env.Code != "0" { + t.Fatalf("login %s %+v %s", email, env, w.Body.String()) + } + var tok authentity.IssuedToken + if err := json.Unmarshal(env.Data, &tok); err != nil { + t.Fatal(err) + } + return tok +} + +func authJSON(t *testing.T, h http.Handler, tok *authentity.IssuedToken, method, path, body string) reply.Envelope { + t.Helper() + var rdr *strings.Reader + if body != "" { + rdr = strings.NewReader(body) + } else { + rdr = strings.NewReader("") + } + req := httptest.NewRequest(method, path, rdr) + req.Header.Set("Authorization", "Bearer "+tok.AccessToken) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + var env reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatal(err) + } + return env +} + +func TestFriendApplyAcceptHTTP(t *testing.T) { + bundle := testFriendEngine(t) + h := bundle.engine + a := loginUser(t, h, "test01@spark.com", 1) + b := loginUser(t, h, "test02@spark.com", 2) + + apply := authJSON(t, h, &a, http.MethodPost, "/v1/friends/requests", `{"email":"test02@spark.com","reason":"hi","remark":"work"}`) + if apply.Code != "0" { + t.Fatalf("apply %+v", apply) + } + var applied friendlogic.ApplyResult + if err := json.Unmarshal(apply.Data, &applied); err != nil || applied.Status != "pending" || applied.RequestID == 0 { + t.Fatalf("apply data %+v %v", applied, err) + } + again := authJSON(t, h, &a, http.MethodPost, "/v1/friends/requests", `{"email":"test02@spark.com","reason":"again"}`) + var applied2 friendlogic.ApplyResult + _ = json.Unmarshal(again.Data, &applied2) + if applied2.RequestID != applied.RequestID { + t.Fatalf("dedupe %d %d", applied.RequestID, applied2.RequestID) + } + + inbox := authJSON(t, h, &b, http.MethodGet, "/v1/friends/requests", "") + if inbox.Code != "0" || !strings.Contains(string(inbox.Data), `"reason":"again"`) { + t.Fatalf("inbox %+v", inbox) + } + acc := authJSON(t, h, &b, http.MethodPost, "/v1/friends", fmt.Sprintf(`{"request_id":%d,"remark":"ok"}`, applied.RequestID)) + if acc.Code != "0" { + t.Fatalf("accept %+v", acc) + } + got := authJSON(t, h, &a, http.MethodGet, fmt.Sprintf("/v1/friends/%d", b.UserID), "") + if got.Code != "0" || !strings.Contains(string(got.Data), `"remark":"work"`) { + t.Fatalf("get %+v", got) + } + list := authJSON(t, h, &b, http.MethodGet, "/v1/friends", "") + if list.Code != "0" || !strings.Contains(string(list.Data), fmt.Sprintf(`"uid":%d`, a.UserID)) { + t.Fatalf("list %+v", list) + } +} diff --git a/internal/transport/http/group_test.go b/internal/transport/http/group_test.go new file mode 100644 index 0000000..a13be05 --- /dev/null +++ b/internal/transport/http/group_test.go @@ -0,0 +1,85 @@ +package httpx + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "go.uber.org/zap" + + "spark-server/internal/infrastructure/id" + authlogic "spark-server/internal/module/auth/logic" + convlogic "spark-server/internal/module/conversation/logic" + convmodel "spark-server/internal/module/conversation/model" + delivermodel "spark-server/internal/module/deliver/model" + friendlogic "spark-server/internal/module/friend/logic" + friendmodel "spark-server/internal/module/friend/model" + grouplogic "spark-server/internal/module/group/logic" + groupmodel "spark-server/internal/module/group/model" + msgmodel "spark-server/internal/module/message/model" + userlogic "spark-server/internal/module/user/logic" + usermodel "spark-server/internal/module/user/model" +) + +func testGroupEngine(t *testing.T) *ginEngineBundle { + t.Helper() + j, err := authlogic.NewJWT(&authlogic.Options{Secret: "test-secret-test-secret", Issuer: "spark-im"}) + if err != nil { + t.Fatal(err) + } + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + friends := friendlogic.NewService( + friendmodel.NewMemoryRepo(), users, ids, + friendlogic.NewGreeter(convlogic.NewP2PService(convmodel.NewMemoryP2P(), ids), msgmodel.NewMemoryRepo(), ids, delivermodel.NewMemoryQueue()), + ) + groups := grouplogic.NewService(groupmodel.NewMemoryRepo(), users, friends, ids, msgmodel.NewMemoryRepo(), delivermodel.NewMemoryQueue(), nil) + r := NewEngine(zap.NewNop(), &EngineDeps{ + Auth: j, + Login: authlogic.NewLoginService(j, users), + Users: users, + Friends: friends, + Groups: groups, + }) + return &ginEngineBundle{engine: r} +} + +func TestGroupCreateHTTP(t *testing.T) { + h := testGroupEngine(t).engine + a := loginUser(t, h, "test01@spark.com", 1) + b := loginUser(t, h, "test02@spark.com", 2) + + apply := authJSON(t, h, &a, http.MethodPost, "/v1/friends/requests", `{"email":"test02@spark.com"}`) + var applied friendlogic.ApplyResult + if err := json.Unmarshal(apply.Data, &applied); err != nil { + t.Fatal(err) + } + acc := authJSON(t, h, &b, http.MethodPost, "/v1/friends", fmt.Sprintf(`{"request_id":%d}`, applied.RequestID)) + if acc.Code != "0" { + t.Fatalf("accept %+v", acc) + } + + created := authJSON(t, h, &a, http.MethodPost, "/v1/groups", fmt.Sprintf(`{"name":"周末局","member_uids":[%d]}`, b.UserID)) + if created.Code != "0" { + t.Fatalf("create %+v", created) + } + var g grouplogic.View + if err := json.Unmarshal(created.Data, &g); err != nil { + t.Fatal(err) + } + if g.ID != 1 || g.ConversationID == 0 || g.ConversationID == g.ID || g.Name != "周末局" { + t.Fatalf("%+v", g) + } + got := authJSON(t, h, &b, http.MethodGet, fmt.Sprintf("/v1/groups/%d", g.ID), "") + if got.Code != "0" { + t.Fatalf("get %+v", got) + } + members := authJSON(t, h, &b, http.MethodGet, fmt.Sprintf("/v1/groups/%d/members", g.ID), "") + if members.Code != "0" { + t.Fatalf("members %+v", members) + } +} diff --git a/internal/transport/http/handler/auth/handler.go b/internal/transport/http/handler/auth/handler.go new file mode 100644 index 0000000..0ae1827 --- /dev/null +++ b/internal/transport/http/handler/auth/handler.go @@ -0,0 +1,67 @@ +package authhandler + +import ( + "github.com/gin-gonic/gin" + + authlogic "spark-server/internal/module/auth/logic" + "spark-server/internal/transport/http/respond" +) + +// Handler is the auth HTTP controller. It must not contain login business rules. +type Handler struct { + login *authlogic.LoginService +} + +func New(login *authlogic.LoginService) *Handler { + return &Handler{login: login} +} + +func (h *Handler) Login(c *gin.Context) { + var in authlogic.LoginInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + tok, err := h.login.Login(c.Request.Context(), &in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, tok) +} + +func (h *Handler) SendEmailCode(c *gin.Context) { + var in authlogic.SendCodeInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + out, err := h.login.SendEmailCode(c.Request.Context(), in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) Refresh(c *gin.Context) { + var in authlogic.RefreshInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + tok, err := h.login.Refresh(c.Request.Context(), in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, tok) +} + +func (h *Handler) Logout(c *gin.Context) { + if err := h.login.Logout(c.Request.Context()); err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, gin.H{}) +} diff --git a/internal/transport/http/handler/favorite/handler.go b/internal/transport/http/handler/favorite/handler.go new file mode 100644 index 0000000..5fbd127 --- /dev/null +++ b/internal/transport/http/handler/favorite/handler.go @@ -0,0 +1,79 @@ +package favhandler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + favlogic "spark-server/internal/module/favorite/logic" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/transport/http/respond" +) + +// Handler is the favorites HTTP controller. +type Handler struct { + favs *favlogic.Service +} + +func New(favs *favlogic.Service) *Handler { + return &Handler{favs: favs} +} + +func (h *Handler) Create(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + var in favlogic.CreateInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + out, err := h.favs.Create(c.Request.Context(), uid, &in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) List(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + limit, _ := strconv.Atoi(c.Query("limit")) + out, err := h.favs.List(c.Request.Context(), uid, limit) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) Delete(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + respond.JSONError(c, errs.ErrInvalidArgument) + return + } + if err := h.favs.Delete(c.Request.Context(), uid, id); err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, gin.H{}) +} + +func currentUID(c *gin.Context) (int64, bool) { + rc := reqctx.From(c.Request.Context()) + if rc == nil || rc.UserID <= 0 { + respond.JSONError(c, errs.ErrUnauthorized) + return 0, false + } + return rc.UserID, true +} diff --git a/internal/transport/http/handler/file/handler.go b/internal/transport/http/handler/file/handler.go new file mode 100644 index 0000000..25dbaa0 --- /dev/null +++ b/internal/transport/http/handler/file/handler.go @@ -0,0 +1,64 @@ +package filehandler + +import ( + "github.com/gin-gonic/gin" + + uploadlogic "spark-server/internal/module/upload/logic" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/transport/http/respond" +) + +// Handler is the files HTTP controller. +type Handler struct { + files *uploadlogic.Service +} + +func New(files *uploadlogic.Service) *Handler { + return &Handler{files: files} +} + +func (h *Handler) Create(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + var in uploadlogic.CreateInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + out, err := h.files.CreateCredential(c.Request.Context(), uid, &in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) Download(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + var in uploadlogic.DownloadInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + out, err := h.files.CreateDownload(c.Request.Context(), uid, &in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func currentUID(c *gin.Context) (int64, bool) { + rc := reqctx.From(c.Request.Context()) + if rc == nil || rc.UserID <= 0 { + respond.JSONError(c, errs.ErrUnauthorized) + return 0, false + } + return rc.UserID, true +} diff --git a/internal/transport/http/handler/friend/handler.go b/internal/transport/http/handler/friend/handler.go new file mode 100644 index 0000000..ed51405 --- /dev/null +++ b/internal/transport/http/handler/friend/handler.go @@ -0,0 +1,131 @@ +package friendhandler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + friendlogic "spark-server/internal/module/friend/logic" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/transport/http/respond" +) + +// Handler is the friends HTTP controller. +type Handler struct { + friends *friendlogic.Service +} + +func New(friends *friendlogic.Service) *Handler { + return &Handler{friends: friends} +} + +func (h *Handler) Lookup(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + in := friendlogic.LookupInput{ + Email: c.Query("email"), + SparkID: c.Query("spark_id"), + QRCode: c.Query("qrcode"), + } + if raw := c.Query("uid"); raw != "" { + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil || id <= 0 { + respond.JSONError(c, errs.ErrInvalidArgument) + return + } + in.UID = id + } + view, err := h.friends.Lookup(c.Request.Context(), uid, in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, view) +} + +func (h *Handler) Apply(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + var in friendlogic.ApplyInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + out, err := h.friends.Apply(c.Request.Context(), uid, &in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) ListRequests(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + out, err := h.friends.ListRequests(c.Request.Context(), uid, queryLimit(c)) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) Accept(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + var in friendlogic.AcceptInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + out, err := h.friends.Accept(c.Request.Context(), uid, in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) List(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + out, err := h.friends.List(c.Request.Context(), uid, queryLimit(c)) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func currentUID(c *gin.Context) (int64, bool) { + rc := reqctx.From(c.Request.Context()) + if rc == nil || rc.UserID <= 0 { + respond.JSONError(c, errs.ErrUnauthorized) + return 0, false + } + return rc.UserID, true +} + +func queryLimit(c *gin.Context) int { + n, _ := strconv.Atoi(c.Query("limit")) + return n +} + +func pathUID(c *gin.Context) (int64, error) { + id, err := strconv.ParseInt(c.Param("uid"), 10, 64) + if err != nil || id <= 0 { + return 0, errs.ErrInvalidArgument + } + return id, nil +} diff --git a/internal/transport/http/handler/friend/member.go b/internal/transport/http/handler/friend/member.go new file mode 100644 index 0000000..44e7e16 --- /dev/null +++ b/internal/transport/http/handler/friend/member.go @@ -0,0 +1,121 @@ +package friendhandler + +import ( + "github.com/gin-gonic/gin" + + friendlogic "spark-server/internal/module/friend/logic" + "spark-server/internal/transport/http/respond" +) + +func (h *Handler) Get(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + friendID, err := pathUID(c) + if err != nil { + respond.JSONError(c, err) + return + } + view, err := h.friends.Get(c.Request.Context(), uid, friendID) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, view) +} + +func (h *Handler) Delete(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + friendID, err := pathUID(c) + if err != nil { + respond.JSONError(c, err) + return + } + if err := h.friends.Delete(c.Request.Context(), uid, friendID); err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, gin.H{}) +} + +func (h *Handler) Remark(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + friendID, err := pathUID(c) + if err != nil { + respond.JSONError(c, err) + return + } + var in friendlogic.RemarkInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + if err := h.friends.SetRemark(c.Request.Context(), uid, friendID, in); err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, gin.H{}) +} + +func (h *Handler) Block(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + targetID, err := pathUID(c) + if err != nil { + respond.JSONError(c, err) + return + } + if err := h.friends.Block(c.Request.Context(), uid, targetID); err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, gin.H{}) +} + +func (h *Handler) Unblock(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + targetID, err := pathUID(c) + if err != nil { + respond.JSONError(c, err) + return + } + if err := h.friends.Unblock(c.Request.Context(), uid, targetID); err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, gin.H{}) +} + +func (h *Handler) Report(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + targetID, err := pathUID(c) + if err != nil { + respond.JSONError(c, err) + return + } + var in friendlogic.ReportInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + if err := h.friends.Report(c.Request.Context(), uid, targetID, in); err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, gin.H{}) +} diff --git a/internal/transport/http/handler/group/handler.go b/internal/transport/http/handler/group/handler.go new file mode 100644 index 0000000..629fc26 --- /dev/null +++ b/internal/transport/http/handler/group/handler.go @@ -0,0 +1,128 @@ +package grouphandler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + grouplogic "spark-server/internal/module/group/logic" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/transport/http/respond" +) + +// Handler is the groups HTTP controller. +type Handler struct { + groups *grouplogic.Service +} + +func New(groups *grouplogic.Service) *Handler { + return &Handler{groups: groups} +} + +func (h *Handler) Create(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + var in grouplogic.CreateInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + out, err := h.groups.Create(c.Request.Context(), uid, &in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) List(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + limit, _ := strconv.Atoi(c.Query("limit")) + out, err := h.groups.List(c.Request.Context(), uid, limit) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) Get(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + id, err := pathID(c) + if err != nil { + respond.JSONError(c, err) + return + } + out, err := h.groups.Get(c.Request.Context(), uid, id) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) Update(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + id, err := pathID(c) + if err != nil { + respond.JSONError(c, err) + return + } + var in grouplogic.UpdateInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + out, err := h.groups.Update(c.Request.Context(), uid, id, &in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) Dissolve(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + id, err := pathID(c) + if err != nil { + respond.JSONError(c, err) + return + } + if err := h.groups.Dissolve(c.Request.Context(), uid, id); err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, gin.H{}) +} + +func currentUID(c *gin.Context) (int64, bool) { + rc := reqctx.From(c.Request.Context()) + if rc == nil || rc.UserID <= 0 { + respond.JSONError(c, errs.ErrUnauthorized) + return 0, false + } + return rc.UserID, true +} + +func pathID(c *gin.Context) (int64, error) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + return 0, errs.ErrInvalidArgument + } + return id, nil +} diff --git a/internal/transport/http/handler/group/member.go b/internal/transport/http/handler/group/member.go new file mode 100644 index 0000000..329658e --- /dev/null +++ b/internal/transport/http/handler/group/member.go @@ -0,0 +1,134 @@ +package grouphandler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + grouplogic "spark-server/internal/module/group/logic" + "spark-server/internal/pkg/errs" + "spark-server/internal/transport/http/respond" +) + +func (h *Handler) ListMembers(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + id, err := pathID(c) + if err != nil { + respond.JSONError(c, err) + return + } + limit, _ := strconv.Atoi(c.Query("limit")) + out, err := h.groups.ListMembers(c.Request.Context(), uid, id, limit) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) Invite(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + id, err := pathID(c) + if err != nil { + respond.JSONError(c, err) + return + } + var in grouplogic.InviteInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + out, err := h.groups.Invite(c.Request.Context(), uid, id, &in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) RemoveMember(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + id, err := pathID(c) + if err != nil { + respond.JSONError(c, err) + return + } + target, err := pathUID(c) + if err != nil { + respond.JSONError(c, err) + return + } + if err := h.groups.RemoveMember(c.Request.Context(), uid, id, target); err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, gin.H{}) +} + +func (h *Handler) UpdateMember(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + id, err := pathID(c) + if err != nil { + respond.JSONError(c, err) + return + } + target, err := pathUID(c) + if err != nil { + respond.JSONError(c, err) + return + } + var in grouplogic.UpdateMemberInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + out, err := h.groups.UpdateMember(c.Request.Context(), uid, id, target, &in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) Transfer(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + id, err := pathID(c) + if err != nil { + respond.JSONError(c, err) + return + } + var in grouplogic.TransferInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + out, err := h.groups.Transfer(c.Request.Context(), uid, id, &in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func pathUID(c *gin.Context) (int64, error) { + id, err := strconv.ParseInt(c.Param("uid"), 10, 64) + if err != nil || id <= 0 { + return 0, errs.ErrInvalidArgument + } + return id, nil +} diff --git a/internal/transport/http/handler/user/handler.go b/internal/transport/http/handler/user/handler.go new file mode 100644 index 0000000..2839f2f --- /dev/null +++ b/internal/transport/http/handler/user/handler.go @@ -0,0 +1,52 @@ +package userhandler + +import ( + "github.com/gin-gonic/gin" + + userlogic "spark-server/internal/module/user/logic" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/transport/http/respond" +) + +// Handler is the users HTTP controller. +type Handler struct { + users *userlogic.Service +} + +func New(users *userlogic.Service) *Handler { + return &Handler{users: users} +} + +func (h *Handler) Me(c *gin.Context) { + rc := reqctx.From(c.Request.Context()) + if rc == nil || rc.UserID <= 0 { + respond.JSONError(c, errs.ErrUnauthorized) + return + } + u, err := h.users.Get(c.Request.Context(), rc.UserID) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, userlogic.PublicProfile(u)) +} + +func (h *Handler) PatchMe(c *gin.Context) { + rc := reqctx.From(c.Request.Context()) + if rc == nil || rc.UserID <= 0 { + respond.JSONError(c, errs.ErrUnauthorized) + return + } + var in userlogic.UpdateProfileInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + u, err := h.users.UpdateProfile(c.Request.Context(), rc.UserID, in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, userlogic.PublicProfile(u)) +} diff --git a/internal/transport/http/middleware.go b/internal/transport/http/middleware.go new file mode 100644 index 0000000..94d0ab2 --- /dev/null +++ b/internal/transport/http/middleware.go @@ -0,0 +1,150 @@ +package httpx + +import ( + "bytes" + "io" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "spark-server/internal/infrastructure/logger" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reply" + "spark-server/internal/pkg/reqctx" +) + +const ( + headerRequestID = "X-Request-ID" + headerTraceID = "X-Trace-ID" +) + +func Trace(base *zap.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + reqID := c.GetHeader(headerRequestID) + if reqID == "" { + reqID = reqctx.NewID() + } + traceID := c.GetHeader(headerTraceID) + if traceID == "" { + traceID = reqID + } + rc := &reqctx.RequestContext{ + TraceID: traceID, + RequestID: reqID, + RemoteAddr: c.Request.RemoteAddr, + RealIP: reqctx.ParseHTTPRealIP( + c.GetHeader(headerRealIP), + c.GetHeader(headerForwardedFor), + c.Request.RemoteAddr, + ), + AppVersion: c.GetHeader(headerAppVersion), + Language: requestLanguage(c), + } + ctx := reqctx.With(c.Request.Context(), rc) + c.Request = c.Request.WithContext(logger.Attach(ctx, base)) + c.Header(headerRequestID, reqID) + c.Header(headerTraceID, traceID) + c.Next() + } +} + +func Recovery(_ *zap.Logger) gin.HandlerFunc { + return gin.CustomRecovery(func(c *gin.Context, recovered any) { + logger.From(c.Request.Context()).Error("panic", zap.Any("panic", recovered), zap.String("path", c.Request.URL.Path)) + c.Abort() + c.JSON(http.StatusOK, reply.FromContext(c.Request.Context(), errs.ErrInner.WithInnerMsg("panic"))) + }) +} + +func AccessLog() gin.HandlerFunc { + return func(c *gin.Context) { + if skipAccessLog(c.Request.URL.Path) { + c.Next() + return + } + reqBody := readRequestBody(c) + bw := &bodyWriter{ResponseWriter: c.Writer} + c.Writer = bw + start := time.Now() + c.Next() + logger.From(c.Request.Context()).Info("http_access", + zap.String("method", c.Request.Method), + zap.String("path", c.Request.URL.Path), + zap.Int("status", c.Writer.Status()), + zap.Int64("latency_ms", time.Since(start).Milliseconds()), + zap.String("request_body", logger.ClipBody(reqBody)), + zap.String("response_body", logger.ClipBody(bw.buf.Bytes())), + ) + } +} + +func skipAccessLog(path string) bool { + switch path { + case "/healthz", "/readyz", "/openapi.yaml", "/docs", "/docs/": + return true + default: + return strings.HasPrefix(path, "/docs/") + } +} + +func readRequestBody(c *gin.Context) []byte { + if c.Request.Body == nil { + return nil + } + if ct := c.ContentType(); strings.HasPrefix(ct, "multipart/") { + return []byte("") + } + raw, err := io.ReadAll(io.LimitReader(c.Request.Body, int64(logger.MaxBody)+1)) + if err != nil { + return nil + } + _ = c.Request.Body.Close() + c.Request.Body = io.NopCloser(bytes.NewReader(raw)) + return raw +} + +type bodyWriter struct { + gin.ResponseWriter + buf bytes.Buffer +} + +func (w *bodyWriter) Write(b []byte) (int, error) { + w.capture(b) + return w.ResponseWriter.Write(b) +} + +func (w *bodyWriter) WriteString(s string) (int, error) { + if w.buf.Len() < logger.MaxBody { + remain := logger.MaxBody - w.buf.Len() + if len(s) < remain { + _, _ = w.buf.WriteString(s) + } else { + _, _ = w.buf.WriteString(s[:remain]) + } + } + return w.ResponseWriter.WriteString(s) +} + +func (w *bodyWriter) capture(b []byte) { + if w.buf.Len() >= logger.MaxBody { + return + } + remain := logger.MaxBody - w.buf.Len() + if len(b) < remain { + _, _ = w.buf.Write(b) + return + } + _, _ = w.buf.Write(b[:remain]) +} + +func requestLanguage(c *gin.Context) string { + raw := c.GetHeader(headerXLanguage) + if raw == "" { + raw = c.GetHeader(headerLanguage) + } + return i18n.Parse(raw).String() +} diff --git a/internal/transport/http/respond/respond.go b/internal/transport/http/respond/respond.go new file mode 100644 index 0000000..509bc13 --- /dev/null +++ b/internal/transport/http/respond/respond.go @@ -0,0 +1,39 @@ +package respond + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "spark-server/internal/infrastructure/logger" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reply" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" +) + +func JSON(c *gin.Context, data any) { + c.JSON(http.StatusOK, reply.OK(data)) +} + +func JSONError(c *gin.Context, err error) { + if app, ok := errs.AsAppError(err); ok && app.LogPrint { + logger.From(c.Request.Context()).Error("app_error", + zap.String("code", app.Code), + zap.String("msg", app.Msg), + zap.String("inner_msg", app.InnerMsg), + ) + } + c.JSON(http.StatusOK, reply.FromContext(c.Request.Context(), err)) +} + +// BindJSON decodes JSON then runs go-playground/validator. label/errmsg are i18n keys. +func BindJSON(c *gin.Context, dst any) error { + lang := reqctx.Lang(c.Request.Context()) + if err := c.ShouldBindJSON(dst); err != nil { + return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyValidateInvalidJSON, nil)) + } + return validate.StructLang(lang, dst) +} diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go new file mode 100644 index 0000000..9e91196 --- /dev/null +++ b/internal/transport/http/router.go @@ -0,0 +1,146 @@ +package httpx + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reply" + "spark-server/internal/pkg/reqctx" + authhandler "spark-server/internal/transport/http/handler/auth" + favhandler "spark-server/internal/transport/http/handler/favorite" + filehandler "spark-server/internal/transport/http/handler/file" + friendhandler "spark-server/internal/transport/http/handler/friend" + grouphandler "spark-server/internal/transport/http/handler/group" + userhandler "spark-server/internal/transport/http/handler/user" + "spark-server/internal/transport/http/respond" +) + +func registerRoutes(r *gin.Engine, deps *EngineDeps) { + registerDocs(r) + registerHealth(r, deps.Health) + v1 := r.Group("/v1") + registerAuthRoutes(v1, deps) + registerUserRoutes(v1, deps) + registerFriendRoutes(v1, deps) + registerGroupRoutes(v1, deps) + registerFileRoutes(v1, deps) + registerFavoriteRoutes(v1, deps) +} + +func registerHealth(r *gin.Engine, h Health) { + r.GET("/healthz", func(c *gin.Context) { + JSON(c, gin.H{"status": "ok"}) + }) + r.GET("/readyz", func(c *gin.Context) { + if h == nil { + JSON(c, gin.H{"status": "ok", "ready": true}) + return + } + ok, data := h.Ready(c.Request.Context()) + if ok { + JSON(c, data) + return + } + lang := reqctx.Lang(c.Request.Context()) + c.JSON(http.StatusServiceUnavailable, reply.FailData( + errs.ErrAvailable.Code, + i18n.ErrorMsg(lang, errs.ErrAvailable.Code), + data, + )) + }) +} + +func registerAuthRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { + g := v1.Group("/auth") + if deps.Login == nil { + return + } + h := authhandler.New(deps.Login) + g.POST("/login", h.Login) + g.POST("/email-codes", h.SendEmailCode) + g.POST("/token/refresh", h.Refresh) + g.POST("/logout", h.Logout) +} + +func registerUserRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { + if deps.Users == nil { + return + } + h := userhandler.New(deps.Users) + g := v1.Group("/users") + g.GET("/me", h.Me) + g.PATCH("/me", h.PatchMe) +} + +func registerFriendRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { + if deps.Friends == nil { + return + } + h := friendhandler.New(deps.Friends) + g := v1.Group("/friends") + g.GET("/lookup", h.Lookup) + g.GET("/requests", h.ListRequests) + g.POST("/requests", h.Apply) + g.GET("", h.List) + g.POST("", h.Accept) + g.GET("/:uid", h.Get) + g.DELETE("/:uid", h.Delete) + g.PUT("/:uid/remark", h.Remark) + g.PUT("/:uid/block", h.Block) + g.DELETE("/:uid/block", h.Unblock) + g.POST("/:uid/reports", h.Report) +} + +func registerGroupRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { + if deps.Groups == nil { + return + } + h := grouphandler.New(deps.Groups) + g := v1.Group("/groups") + g.POST("", h.Create) + g.GET("", h.List) + g.GET("/:id/members", h.ListMembers) + g.POST("/:id/members", h.Invite) + g.PATCH("/:id/members/:uid", h.UpdateMember) + g.DELETE("/:id/members/:uid", h.RemoveMember) + g.POST("/:id/transfer", h.Transfer) + g.GET("/:id", h.Get) + g.PATCH("/:id", h.Update) + g.DELETE("/:id", h.Dissolve) +} + +func registerFileRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { + if deps.Files == nil { + return + } + h := filehandler.New(deps.Files) + g := v1.Group("/files") + g.POST("", h.Create) + g.POST("/downloads", h.Download) +} + +func registerFavoriteRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { + if deps.Favorites == nil { + return + } + h := favhandler.New(deps.Favorites) + g := v1.Group("/favorites") + g.GET("", h.List) + g.POST("", h.Create) + g.DELETE("/:id", h.Delete) +} + +func JSON(c *gin.Context, data any) { + respond.JSON(c, data) +} + +func JSONError(c *gin.Context, err error) { + respond.JSONError(c, err) +} + +func BindJSON(c *gin.Context, dst any) error { + return respond.BindJSON(c, dst) +} diff --git a/internal/transport/http/server.go b/internal/transport/http/server.go index ef8fbc6..5bea5a4 100644 --- a/internal/transport/http/server.go +++ b/internal/transport/http/server.go @@ -2,27 +2,62 @@ package httpx import ( "context" - "log/slog" + "fmt" + "net" "net/http" + "sync" + "sync/atomic" "time" "github.com/gin-gonic/gin" + "go.uber.org/zap" + + authlogic "spark-server/internal/module/auth/logic" + favlogic "spark-server/internal/module/favorite/logic" + friendlogic "spark-server/internal/module/friend/logic" + grouplogic "spark-server/internal/module/group/logic" + uploadlogic "spark-server/internal/module/upload/logic" + userlogic "spark-server/internal/module/user/logic" + "spark-server/internal/pkg/ratelimit" ) type Server struct { - log *slog.Logger - http *http.Server + log *zap.Logger + http *http.Server + ready chan struct{} + readyOnce sync.Once + listening atomic.Bool } -func New(log *slog.Logger, addr string) *Server { - gin.SetMode(gin.ReleaseMode) - r := gin.New() - r.Use(gin.Recovery()) - r.GET("/healthz", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok"}) - }) +// Health is the readiness checker. Nil means tests: /readyz is always ready. +type Health interface { + Ready(ctx context.Context) (ok bool, data any) +} + +// EngineDeps wires Application services into Gin. Auth nil disables Bearer checks (tests). +type EngineDeps struct { + Auth authlogic.Service + Login *authlogic.LoginService + Users *userlogic.Service + Friends *friendlogic.Service + Groups *grouplogic.Service + Files *uploadlogic.Service + Favorites *favlogic.Service + Health Health + AuthLimiter *ratelimit.Limiter +} + +func New(log *zap.Logger, addr string, deps *EngineDeps) *Server { + if log == nil { + log = zap.NewNop() + } + if deps == nil { + deps = &EngineDeps{} + } + r := NewEngine(log, deps) return &Server{ - log: log, + log: log, + ready: make(chan struct{}), http: &http.Server{ Addr: addr, Handler: r, @@ -31,15 +66,50 @@ func New(log *slog.Logger, addr string) *Server { } } +func NewEngine(log *zap.Logger, deps *EngineDeps) *gin.Engine { + gin.SetMode(gin.ReleaseMode) + r := gin.New() + _ = r.SetTrustedProxies([]string{"127.0.0.1", "::1"}) + if deps == nil { + deps = &EngineDeps{} + } + r.Use(Trace(log), Recovery(log), AccessLog(), AuthRateLimit(deps.AuthLimiter), Auth(deps.Auth)) + registerRoutes(r, deps) + return r +} + func (s *Server) Start() error { - s.log.Info("http listening", "addr", s.http.Addr) - err := s.http.ListenAndServe() + ln, err := net.Listen("tcp", s.http.Addr) + if err != nil { + s.signalReady() + return err + } + s.listening.Store(true) + s.signalReady() + s.log.Info("http listening", zap.String("addr", ln.Addr().String())) + err = s.http.Serve(ln) if err == http.ErrServerClosed { return nil } return err } +func (s *Server) WaitReady(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-s.ready: + if s.listening.Load() { + return nil + } + return fmt.Errorf("http not listening") + } +} + +func (s *Server) signalReady() { + s.readyOnce.Do(func() { close(s.ready) }) +} + func (s *Server) Stop(ctx context.Context) error { return s.http.Shutdown(ctx) } diff --git a/internal/transport/http/server_test.go b/internal/transport/http/server_test.go new file mode 100644 index 0000000..48c1ddc --- /dev/null +++ b/internal/transport/http/server_test.go @@ -0,0 +1,434 @@ +package httpx + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "spark-server/internal/infrastructure/id" + "spark-server/internal/infrastructure/logger" + authentity "spark-server/internal/module/auth/entity" + authlogic "spark-server/internal/module/auth/logic" + userlogic "spark-server/internal/module/user/logic" + usermodel "spark-server/internal/module/user/model" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/ratelimit" + "spark-server/internal/pkg/reply" + "spark-server/internal/pkg/reqctx" +) + +func TestHealthzEnvelopeAndTraceHeaders(t *testing.T) { + r := NewEngine(zap.NewNop(), &EngineDeps{}) + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + req.Header.Set("X-Request-ID", "req-1") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + if w.Header().Get("X-Request-ID") != "req-1" || w.Header().Get("X-Trace-ID") != "req-1" { + t.Fatalf("headers %v", w.Header()) + } + var env reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatal(err) + } + if env.Code != "0" || env.Msg != "ok" { + t.Fatalf("%+v", env) + } + if !bytes.Contains(env.Data, []byte(`"status":"ok"`)) { + t.Fatalf("data %s", env.Data) + } +} + +type stubHealth struct { + ok bool + data any +} + +func (s stubHealth) Ready(context.Context) (bool, any) { return s.ok, s.data } + +func TestReadyzHTTPStatus(t *testing.T) { + r := NewEngine(zap.NewNop(), &EngineDeps{Health: stubHealth{ok: true, data: gin.H{"status": "ok", "mysql": "ok"}}}) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if w.Code != http.StatusOK { + t.Fatalf("ready %d %s", w.Code, w.Body.String()) + } + r = NewEngine(zap.NewNop(), &EngineDeps{Health: stubHealth{ok: false, data: gin.H{"status": "not_ready", "mysql": "down"}}}) + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("not ready %d", w.Code) + } + var env reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatal(err) + } + if env.Code != errs.ErrAvailable.Code || !bytes.Contains(env.Data, []byte(`"mysql":"down"`)) { + t.Fatalf("%+v", env) + } +} + +func TestDocsAndOpenAPI(t *testing.T) { + r := NewEngine(zap.NewNop(), &EngineDeps{}) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/openapi.yaml", nil)) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "openapi: 3.0.3") || !strings.Contains(w.Body.String(), "bearerAuth") { + t.Fatalf("spec %d %s", w.Code, w.Body.String()) + } + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs", nil)) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "swagger-ui") { + t.Fatalf("docs %d", w.Code) + } +} + +func TestJSONErrorEnvelope(t *testing.T) { + r := NewEngine(zap.NewNop(), &EngineDeps{}) + r.POST("/v1/probe", func(c *gin.Context) { + JSONError(c, errs.ErrNotFound.WithMsg("missing")) + }) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/probe", strings.NewReader(`{"token":"secret"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + var env reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatal(err) + } + if env.Code != errs.ErrNotFound.Code || env.Msg != "missing" { + t.Fatalf("%+v", env) + } +} + +func TestClipBodyRedactsToken(t *testing.T) { + got := logger.ClipBody([]byte(`{"token":"abc","access_token":"jwt","refresh_token":"rt","verify_code":"654321","upload_url":"http://x?sig=1","x":1}`)) + if strings.Contains(got, "abc") || strings.Contains(got, "jwt") || strings.Contains(got, "rt") || strings.Contains(got, "654321") || strings.Contains(got, "sig=1") || !strings.Contains(got, "***") { + t.Fatalf("redact %s", got) + } +} + +func testLoginEngine(t *testing.T) *gin.Engine { + t.Helper() + j, err := authlogic.NewJWT(&authlogic.Options{Secret: "test-secret-test-secret", Issuer: "spark-im"}) + if err != nil { + t.Fatal(err) + } + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + return NewEngine(zap.NewNop(), &EngineDeps{ + Auth: j, + Login: authlogic.NewLoginService(j, users), + Users: users, + }) +} + +func TestLoginAndBearerAuth(t *testing.T) { + r := testLoginEngine(t) + r.GET("/v1/probe", func(c *gin.Context) { + rc := reqctx.From(c.Request.Context()) + JSON(c, gin.H{"user_id": rc.UserID, "device_id": rc.DeviceID}) + }) + + w := httptest.NewRecorder() + loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios","device_name":"iPhone"}`)) + loginReq.Header.Set("Content-Type", "application/json") + loginReq.Header.Set("X-App-Version", "1.0.0") + loginReq.Header.Set("X-Real-IP", "203.0.113.10") + r.ServeHTTP(w, loginReq) + var loginEnv reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &loginEnv); err != nil { + t.Fatal(err) + } + if loginEnv.Code != "0" { + t.Fatalf("login %+v body=%s", loginEnv, w.Body.String()) + } + var tok authentity.IssuedToken + if err := json.Unmarshal(loginEnv.Data, &tok); err != nil { + t.Fatal(err) + } + if tok.AccessToken == "" || tok.RefreshToken == "" || tok.UserID <= 0 || tok.DeviceID != 7 || tok.ExpiresIn != 604800 || tok.RefreshExpiresIn != 2592000 || tok.SparkID == "" { + t.Fatalf("login data %+v", tok) + } + + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/probe", nil)) + var denied reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &denied); err != nil { + t.Fatal(err) + } + if denied.Code != errs.ErrUnauthorized.Code { + t.Fatalf("no bearer %+v", denied) + } + + w = httptest.NewRecorder() + okReq := httptest.NewRequest(http.MethodGet, "/v1/probe", nil) + okReq.Header.Set("Authorization", "Bearer "+tok.AccessToken) + okReq.Header.Set("X-App-Version", "1.0.0") + okReq.Header.Set("Accept-Language", "zh-CN") + r.ServeHTTP(w, okReq) + var okEnv reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &okEnv); err != nil { + t.Fatal(err) + } + uidJSON, _ := json.Marshal(tok.UserID) + if okEnv.Code != "0" || !bytes.Contains(okEnv.Data, uidJSON) || !bytes.Contains(okEnv.Data, []byte(`"device_id":7`)) { + t.Fatalf("authed %+v", okEnv) + } + + w = httptest.NewRecorder() + badDev := httptest.NewRequest(http.MethodGet, "/v1/probe", nil) + badDev.Header.Set("Authorization", "Bearer "+tok.AccessToken) + badDev.Header.Set("X-Device-ID", "99") + r.ServeHTTP(w, badDev) + var mismatch reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &mismatch); err != nil { + t.Fatal(err) + } + if mismatch.Code != errs.ErrUnauthorized.Code { + t.Fatalf("device mismatch %+v", mismatch) + } +} + +func TestUsersMeAndSparkID(t *testing.T) { + r := testLoginEngine(t) + w := httptest.NewRecorder() + loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios"}`)) + loginReq.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, loginReq) + var loginEnv reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &loginEnv); err != nil { + t.Fatal(err) + } + if loginEnv.Code != "0" { + t.Fatalf("login %+v", loginEnv) + } + var tok authentity.IssuedToken + if err := json.Unmarshal(loginEnv.Data, &tok); err != nil { + t.Fatal(err) + } + + w = httptest.NewRecorder() + meReq := httptest.NewRequest(http.MethodGet, "/v1/users/me", nil) + meReq.Header.Set("Authorization", "Bearer "+tok.AccessToken) + r.ServeHTTP(w, meReq) + var meEnv reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &meEnv); err != nil { + t.Fatal(err) + } + hasIDs := bytes.Contains(meEnv.Data, []byte(`"spark_id"`)) && bytes.Contains(meEnv.Data, []byte(`"uid"`)) && bytes.Contains(meEnv.Data, []byte(`"qrcode"`)) + if meEnv.Code != "0" || !hasIDs || bytes.Contains(meEnv.Data, []byte(`spark:u:`)) || bytes.Contains(meEnv.Data, []byte(`spark:q:`)) { + t.Fatalf("me %+v", meEnv) + } + + w = httptest.NewRecorder() + patchReq := httptest.NewRequest(http.MethodPatch, "/v1/users/me", strings.NewReader(`{"spark_id":"alice_ok"}`)) + patchReq.Header.Set("Content-Type", "application/json") + patchReq.Header.Set("Authorization", "Bearer "+tok.AccessToken) + r.ServeHTTP(w, patchReq) + var patchEnv reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &patchEnv); err != nil { + t.Fatal(err) + } + if patchEnv.Code != "0" || !bytes.Contains(patchEnv.Data, []byte(`"alice_ok"`)) { + t.Fatalf("patch %+v", patchEnv) + } + + w = httptest.NewRecorder() + profReq := httptest.NewRequest(http.MethodPatch, "/v1/users/me", strings.NewReader(`{"nickname":"Ada","gender":2,"birthday":"1994-05-01"}`)) + profReq.Header.Set("Content-Type", "application/json") + profReq.Header.Set("Authorization", "Bearer "+tok.AccessToken) + r.ServeHTTP(w, profReq) + var profEnv reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &profEnv); err != nil { + t.Fatal(err) + } + if profEnv.Code != "0" || !bytes.Contains(profEnv.Data, []byte(`"Ada"`)) || !bytes.Contains(profEnv.Data, []byte(`"1994-05-01"`)) { + t.Fatalf("profile patch %+v", profEnv) + } +} + +func TestRefreshAndLogout(t *testing.T) { + r := testLoginEngine(t) + w := httptest.NewRecorder() + loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios"}`)) + loginReq.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, loginReq) + var loginEnv reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &loginEnv); err != nil { + t.Fatal(err) + } + if loginEnv.Code != "0" { + t.Fatalf("login %+v", loginEnv) + } + var tok authentity.IssuedToken + if err := json.Unmarshal(loginEnv.Data, &tok); err != nil { + t.Fatal(err) + } + + w = httptest.NewRecorder() + refReq := httptest.NewRequest(http.MethodPost, "/v1/auth/token/refresh", strings.NewReader(`{"refresh_token":"`+tok.RefreshToken+`"}`)) + refReq.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, refReq) + var refEnv reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &refEnv); err != nil { + t.Fatal(err) + } + if refEnv.Code != "0" { + t.Fatalf("refresh %+v", refEnv) + } + var next authentity.IssuedToken + if err := json.Unmarshal(refEnv.Data, &next); err != nil { + t.Fatal(err) + } + if next.AccessToken == "" || next.AccessToken == tok.AccessToken || next.RefreshToken == tok.RefreshToken { + t.Fatalf("next %+v", next) + } + + w = httptest.NewRecorder() + oldMe := httptest.NewRequest(http.MethodGet, "/v1/users/me", nil) + oldMe.Header.Set("Authorization", "Bearer "+tok.AccessToken) + r.ServeHTTP(w, oldMe) + var oldEnv reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &oldEnv); err != nil { + t.Fatal(err) + } + if oldEnv.Code != errs.ErrUnauthorized.Code { + t.Fatalf("old access %+v", oldEnv) + } + + w = httptest.NewRecorder() + outReq := httptest.NewRequest(http.MethodPost, "/v1/auth/logout", nil) + outReq.Header.Set("Authorization", "Bearer "+next.AccessToken) + r.ServeHTTP(w, outReq) + var outEnv reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &outEnv); err != nil { + t.Fatal(err) + } + if outEnv.Code != "0" { + t.Fatalf("logout %+v", outEnv) + } + + w = httptest.NewRecorder() + meReq := httptest.NewRequest(http.MethodGet, "/v1/users/me", nil) + meReq.Header.Set("Authorization", "Bearer "+next.AccessToken) + r.ServeHTTP(w, meReq) + var meEnv reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &meEnv); err != nil { + t.Fatal(err) + } + if meEnv.Code != errs.ErrUnauthorized.Code { + t.Fatalf("after logout %+v", meEnv) + } +} + +func TestLoginRejectsBadInput(t *testing.T) { + r := testLoginEngine(t) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + var env reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatal(err) + } + if env.Code != errs.ErrInvalidArgument.Code || !strings.Contains(env.Msg, "请填写验证码") { + t.Fatalf("%+v", env) + } + + w = httptest.NewRecorder() + enReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com"}`)) + enReq.Header.Set("Content-Type", "application/json") + enReq.Header.Set("Accept-Language", "en") + r.ServeHTTP(w, enReq) + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatal(err) + } + if env.Code != errs.ErrInvalidArgument.Code || !strings.Contains(env.Msg, "Please enter the verification code") { + t.Fatalf("en %+v", env) + } +} + +func TestHTTPRealIPOnContext(t *testing.T) { + r := NewEngine(zap.NewNop(), &EngineDeps{}) + r.GET("/v1/probe-ip", func(c *gin.Context) { + JSON(c, gin.H{"real_ip": reqctx.RealIP(c.Request.Context())}) + }) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/probe-ip", nil) + req.Header.Set("X-Real-IP", "203.0.113.10") + r.ServeHTTP(w, req) + var env reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatal(err) + } + if env.Code != "0" || !bytes.Contains(env.Data, []byte(`"real_ip":"203.0.113.10"`)) { + t.Fatalf("%+v", env) + } +} + +func TestHTTPLanguageOnContext(t *testing.T) { + r := NewEngine(zap.NewNop(), &EngineDeps{}) + r.GET("/v1/probe-lang", func(c *gin.Context) { + JSON(c, gin.H{"lang": reqctx.Language(c.Request.Context())}) + }) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/probe-lang", nil) + req.Header.Set("X-Language", "zh-TW") + r.ServeHTTP(w, req) + var env reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatal(err) + } + if env.Code != "0" || !bytes.Contains(env.Data, []byte(`"lang":"zh-TW"`)) { + t.Fatalf("%+v", env) + } +} + +func TestLoginRateLimitedByRealIP(t *testing.T) { + j, err := authlogic.NewJWT(&authlogic.Options{Secret: "test-secret-test-secret", Issuer: "spark-im"}) + if err != nil { + t.Fatal(err) + } + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + r := NewEngine(zap.NewNop(), &EngineDeps{ + Auth: j, + Login: authlogic.NewLoginService(j, users), + Users: users, + AuthLimiter: ratelimit.New(1), + }) + body := `{"email":"test01@spark.com","verify_code":"x","device_id":7,"device_type":"ios"}` + hit := func() reply.Envelope { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Real-IP", "203.0.113.10") + r.ServeHTTP(w, req) + var env reply.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatal(err) + } + return env + } + if env := hit(); env.Code != "0" { + t.Fatalf("first %+v", env) + } + if env := hit(); env.Code != errs.ErrRateLimited.Code { + t.Fatalf("second %+v", env) + } +} diff --git a/internal/transport/tcp/dispatch.go b/internal/transport/tcp/dispatch.go index b955af5..752c687 100644 --- a/internal/transport/tcp/dispatch.go +++ b/internal/transport/tcp/dispatch.go @@ -2,8 +2,13 @@ package tcp import ( "context" + "strconv" + "go.uber.org/zap" + + "spark-server/internal/infrastructure/logger" "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" "spark-server/internal/pkg/reqctx" "spark-server/internal/transport/tcp/protocol" "spark-server/internal/transport/tcp/session" @@ -11,7 +16,13 @@ import ( func (s *Server) handleInbound(st *connState, frame protocol.Frame) { st.sess.Touch() - if frame.Command != protocol.CmdHeartbeat && frame.Command != protocol.CmdPushAck && !st.limit.Allow() { + if frame.Command == protocol.CmdAuth { + if !s.allowAuthIP(st.sess.RealIP()) { + s.metrics.Inc("tcp_auth_rate_limited_total") + s.replyErr(st, frame, errs.ErrRateLimited) + return + } + } else if frame.Command != protocol.CmdHeartbeat && frame.Command != protocol.CmdPushAck && !st.limit.Allow() { s.metrics.Inc("tcp_rate_limited_total") s.replyErr(st, frame, errs.ErrRateLimited) return @@ -22,7 +33,7 @@ func (s *Server) handleInbound(st *connState, frame protocol.Frame) { } switch frame.Command { case protocol.CmdHeartbeat: - s.writeEncoded(st, replyFrame(protocol.CmdHeartbeat, frame.SequenceID, frame.RequestID, nil, 0)) + s.writeEncoded(st, replyFrame(protocol.CmdHeartbeat, frame.SequenceID, frame.RequestID, protocol.EncodeOK(nil), 0)) case protocol.CmdPushAck: return default: @@ -43,7 +54,13 @@ func ensureAuth(conn *session.Conn, cmd uint16) error { } func (s *Server) submitBusiness(st *connState, frame protocol.Frame) { - ctx := reqctx.With(context.Background(), st.sess.RequestContext()) + rc := st.sess.RequestContext() + if frame.RequestID != 0 { + rc.RequestID = strconv.FormatUint(frame.RequestID, 10) + } else { + rc.RequestID = reqctx.NewID() + } + ctx := logger.Attach(reqctx.With(context.Background(), rc), s.log) copied := frame err := s.pool.Submit(ctx, func() { s.runHandler(ctx, st.sess, copied) @@ -58,24 +75,46 @@ func (s *Server) runHandler(ctx context.Context, conn *session.Conn, frame proto ctx, cancel := context.WithTimeout(ctx, s.handlerTimeout) defer cancel() resp, err := s.router.Dispatch(ctx, conn, frame) + var respBody []byte if err != nil { + s.logAppErr(ctx, err) + respBody = protocol.EncodeErrLang(i18n.Parse(conn.Language()), err) _ = conn.Send(&session.Packet{ - Command: frame.Command, SequenceID: frame.SequenceID, RequestID: frame.RequestID, - Body: protocol.EncodeError(errorCode(err)), + Command: frame.Command, SequenceID: frame.SequenceID, RequestID: frame.RequestID, Body: respBody, + }) + } else if resp != nil { + respBody = resp.Body + _ = conn.Send(&session.Packet{ + Command: resp.Command, SequenceID: resp.SequenceID, RequestID: resp.RequestID, Flags: resp.Flags, Body: resp.Body, }) - return } - if resp == nil { - return - } - _ = conn.Send(&session.Packet{ - Command: resp.Command, SequenceID: resp.SequenceID, RequestID: resp.RequestID, Flags: resp.Flags, Body: resp.Body, - }) + logger.From(ctx).Info("tcp_access", + zap.Uint16("cmd", frame.Command), + zap.Uint64("frame_request_id", frame.RequestID), + zap.String("request_body", logger.ClipBody(frame.Body)), + zap.String("response_body", logger.ClipBody(respBody)), + ) } func (s *Server) replyErr(st *connState, frame protocol.Frame, err error) { - body := protocol.EncodeError(errorCode(err)) - s.writeEncoded(st, replyFrame(frame.Command, frame.SequenceID, frame.RequestID, body, 0)) + rc := st.sess.RequestContext() + if frame.RequestID != 0 { + rc.RequestID = strconv.FormatUint(frame.RequestID, 10) + } + s.logAppErr(logger.Attach(reqctx.With(context.Background(), rc), s.log), err) + s.writeEncoded(st, replyFrame(frame.Command, frame.SequenceID, frame.RequestID, protocol.EncodeErrLang(i18n.Parse(st.sess.Language()), err), 0)) +} + +func (s *Server) logAppErr(ctx context.Context, err error) { + app, ok := errs.AsAppError(err) + if !ok || app == nil || !app.LogPrint { + return + } + logger.From(ctx).Error("app_error", + zap.String("code", app.Code), + zap.String("msg", app.Msg), + zap.String("inner_msg", app.InnerMsg), + ) } func (s *Server) writeEncoded(st *connState, frame protocol.Frame) { diff --git a/internal/transport/tcp/errors.go b/internal/transport/tcp/errors.go index 4567237..6fc497b 100644 --- a/internal/transport/tcp/errors.go +++ b/internal/transport/tcp/errors.go @@ -1,35 +1,9 @@ package tcp import ( - "errors" - - "spark-server/internal/pkg/errs" "spark-server/internal/transport/tcp/protocol" ) -func errorCode(err error) protocol.ErrorBody { - switch { - case err == nil: - return protocol.ErrorBody{Code: protocol.CodeOK} - case errors.Is(err, errs.ErrUnauthorized): - return protocol.ErrorBody{Code: protocol.CodeUnauthorized, Message: err.Error()} - case errors.Is(err, errs.ErrNotAuthenticated): - return protocol.ErrorBody{Code: protocol.CodeNotAuth, Message: err.Error()} - case errors.Is(err, errs.ErrForbidden): - return protocol.ErrorBody{Code: protocol.CodeForbidden, Message: err.Error()} - case errors.Is(err, errs.ErrNotFound): - return protocol.ErrorBody{Code: protocol.CodeNotFound, Message: err.Error()} - case errors.Is(err, errs.ErrInvalidArgument), errors.Is(err, errs.ErrInvalidFrame), errors.Is(err, errs.ErrFrameTooLarge): - return protocol.ErrorBody{Code: protocol.CodeInvalid, Message: err.Error()} - case errors.Is(err, errs.ErrBusy), errors.Is(err, errs.ErrOutboundDropped): - return protocol.ErrorBody{Code: protocol.CodeBusy, Message: err.Error()} - case errors.Is(err, errs.ErrRateLimited): - return protocol.ErrorBody{Code: protocol.CodeRateLimited, Message: err.Error()} - default: - return protocol.ErrorBody{Code: protocol.CodeInternal, Message: "internal"} - } -} - func replyFrame(cmd uint16, seq uint32, req uint64, body []byte, flags uint16) protocol.Frame { return protocol.Frame{Magic: protocol.Magic, Version: protocol.CurrentVersion, Command: cmd, Flags: flags, RequestID: req, SequenceID: seq, Body: body} } diff --git a/internal/transport/tcp/handlers.go b/internal/transport/tcp/handlers.go index 251ef68..cbf07de 100644 --- a/internal/transport/tcp/handlers.go +++ b/internal/transport/tcp/handlers.go @@ -4,6 +4,7 @@ import ( "context" "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" "spark-server/internal/transport/tcp/protocol" "spark-server/internal/transport/tcp/session" ) @@ -21,20 +22,20 @@ func (s *Server) handleAuth(ctx context.Context, conn *session.Conn, frame proto if err != nil { return nil, errs.ErrInvalidArgument } + conn.SetLanguage(i18n.Parse(req.Language).String()) principal, err := s.auth.Authenticate(ctx, req.Token) if err != nil { return nil, err } - deviceID := principal.DeviceID - if req.DeviceID != 0 { - deviceID = req.DeviceID + if req.DeviceID != 0 && req.DeviceID != principal.DeviceID { + return nil, errs.ErrUnauthorized } - conn.Authenticate(principal.UserID, deviceID) + conn.Authenticate(principal.UserID, principal.DeviceID) if err := s.sessions.Bind(conn); err != nil { return nil, err } conn.Activate() - body := protocol.EncodeAuthResponse(protocol.AuthResponse{UserID: principal.UserID, DeviceID: deviceID}) + body := protocol.EncodeOK(protocol.AuthResponse{UserID: principal.UserID, DeviceID: principal.DeviceID}) return &protocol.Frame{ Version: protocol.CurrentVersion, Command: protocol.CmdAuth, RequestID: frame.RequestID, SequenceID: frame.SequenceID, Body: body, diff --git a/internal/transport/tcp/protocol/codes.go b/internal/transport/tcp/protocol/codes.go index 50a46f6..a3bbc89 100644 --- a/internal/transport/tcp/protocol/codes.go +++ b/internal/transport/tcp/protocol/codes.go @@ -1,16 +1,16 @@ package protocol const ( - CodeOK int32 = 0 - CodeUnauthorized int32 = 40101 - CodeForbidden int32 = 40301 - CodeNotFound int32 = 40401 - CodeInvalid int32 = 40001 - CodeBusy int32 = 42901 - CodeRateLimited int32 = 42902 - CodeInternal int32 = 50001 - CodeNotAuth int32 = 40102 - CodeOutboundDrop int32 = 42903 + CodeOK = "0" + CodeUnauthorized = "40101" + CodeForbidden = "40301" + CodeNotFound = "40401" + CodeInvalid = "40001" + CodeBusy = "42901" + CodeRateLimited = "42902" + CodeInternal = "50001" + CodeNotAuth = "40102" + CodeOutboundDrop = "42903" ) const CurrentVersion uint8 = 1 diff --git a/internal/transport/tcp/protocol/envelope.go b/internal/transport/tcp/protocol/envelope.go new file mode 100644 index 0000000..4f49535 --- /dev/null +++ b/internal/transport/tcp/protocol/envelope.go @@ -0,0 +1,22 @@ +package protocol + +import ( + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reply" +) + +func EncodeOK(data any) []byte { + return reply.MustMarshal(reply.OK(data)) +} + +func EncodeErr(err error) []byte { + return EncodeErrLang(i18n.Default, err) +} + +func EncodeErrLang(lang i18n.Lang, err error) []byte { + return reply.MustMarshal(reply.FromErrorLang(lang, err)) +} + +func DecodeEnvelope(body []byte) (reply.Envelope, error) { + return reply.Unmarshal(body) +} diff --git a/internal/transport/tcp/protocol/payload.go b/internal/transport/tcp/protocol/payload.go index 46244cf..36302f8 100644 --- a/internal/transport/tcp/protocol/payload.go +++ b/internal/transport/tcp/protocol/payload.go @@ -1,146 +1,69 @@ package protocol import ( - "spark-server/internal/pkg/errs" + "encoding/json" - "google.golang.org/protobuf/encoding/protowire" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reply" ) type AuthRequest struct { - Token string - DeviceID uint64 - AppVersion string - Language string + Token string `json:"token"` + DeviceID uint64 `json:"device_id"` + AppVersion string `json:"app_version"` + Language string `json:"language"` } type AuthResponse struct { - UserID int64 - DeviceID uint64 + UserID int64 `json:"user_id"` + DeviceID uint64 `json:"device_id"` +} + +type MsgIDBody struct { + MsgID int64 `json:"msg_id"` +} + +type KickBody struct { + Reason string `json:"reason"` } type ErrorBody struct { - Code int32 + Code string Message string } func EncodeAuthRequest(a AuthRequest) []byte { - out := appendStringField(nil, 1, a.Token) - out = appendVarintField(out, 2, a.DeviceID) - out = appendStringField(out, 3, a.AppVersion) - out = appendStringField(out, 4, a.Language) - return out + b, _ := json.Marshal(a) + return b } func DecodeAuthRequest(body []byte) (AuthRequest, error) { var req AuthRequest - for len(body) > 0 { - num, typ, tagLen := protowire.ConsumeTag(body) - if tagLen < 0 { - return AuthRequest{}, errs.ErrInvalidFrame - } - body = body[tagLen:] - val, n, err := consumeField(typ, body) - if err != nil { - return AuthRequest{}, err - } - body = body[n:] - assignAuth(&req, num, typ, val) + if err := json.Unmarshal(body, &req); err != nil { + return AuthRequest{}, errs.ErrInvalidFrame + } + if req.Token == "" { + return AuthRequest{}, errs.ErrInvalidArgument } return req, nil } -func assignAuth(req *AuthRequest, num protowire.Number, typ protowire.Type, val []byte) { - switch { - case num == 1 && typ == protowire.BytesType: - req.Token = string(val) - case num == 2 && typ == protowire.VarintType: - v, _ := protowire.ConsumeVarint(val) - req.DeviceID = v - case num == 3 && typ == protowire.BytesType: - req.AppVersion = string(val) - case num == 4 && typ == protowire.BytesType: - req.Language = string(val) - } -} - -func EncodeAuthResponse(a AuthResponse) []byte { - out := appendVarintField(nil, 1, uint64(a.UserID)) - return appendVarintField(out, 2, a.DeviceID) -} - func EncodeError(e ErrorBody) []byte { - out := appendVarintField(nil, 1, uint64(e.Code)) - return appendStringField(out, 2, e.Message) + return reply.MustMarshal(reply.Fail(e.Code, e.Message)) } -// DecodeError reads a protobuf-wire error payload. func DecodeError(body []byte) (ErrorBody, error) { - var out ErrorBody - for len(body) > 0 { - num, typ, tagLen := protowire.ConsumeTag(body) - if tagLen < 0 { - return ErrorBody{}, errs.ErrInvalidFrame - } - body = body[tagLen:] - val, n, err := consumeField(typ, body) - if err != nil { - return ErrorBody{}, err - } - body = body[n:] - if num == 1 && typ == protowire.VarintType { - v, _ := protowire.ConsumeVarint(val) - out.Code = int32(v) - } - if num == 2 && typ == protowire.BytesType { - out.Message = string(val) - } + env, err := reply.Unmarshal(body) + if err != nil { + return ErrorBody{}, err } - return out, nil + return ErrorBody{Code: env.Code, Message: env.Msg}, nil } func EncodeMsgID(id int64) []byte { - return appendVarintField(nil, 1, uint64(id)) + return EncodeOK(MsgIDBody{MsgID: id}) } func EncodeKick(reason string) []byte { - return appendStringField(nil, 1, reason) -} - -func appendStringField(out []byte, field protowire.Number, s string) []byte { - if s == "" { - return out - } - out = protowire.AppendVarint(out, protowire.EncodeTag(field, protowire.BytesType)) - return protowire.AppendString(out, s) -} - -func appendVarintField(out []byte, field protowire.Number, v uint64) []byte { - if v == 0 { - return out - } - out = protowire.AppendVarint(out, protowire.EncodeTag(field, protowire.VarintType)) - return protowire.AppendVarint(out, v) -} - -func consumeField(typ protowire.Type, body []byte) ([]byte, int, error) { - switch typ { - case protowire.VarintType: - v, n := protowire.ConsumeVarint(body) - if n < 0 { - return nil, 0, errs.ErrInvalidFrame - } - return protowire.AppendVarint(nil, v), n, nil - case protowire.BytesType: - v, n := protowire.ConsumeBytes(body) - if n < 0 { - return nil, 0, errs.ErrInvalidFrame - } - return append([]byte(nil), v...), n, nil - default: - n := protowire.ConsumeFieldValue(1, typ, body) - if n < 0 { - return nil, 0, errs.ErrInvalidFrame - } - return nil, n, nil - } + return EncodeOK(KickBody{Reason: reason}) } diff --git a/internal/transport/tcp/protocol/payload_test.go b/internal/transport/tcp/protocol/payload_test.go new file mode 100644 index 0000000..6fb0c31 --- /dev/null +++ b/internal/transport/tcp/protocol/payload_test.go @@ -0,0 +1,20 @@ +package protocol + +import "testing" + +func TestAuthRequestJSONRoundTrip(t *testing.T) { + raw := EncodeAuthRequest(AuthRequest{Token: "abc", DeviceID: 7, AppVersion: "1.0", Language: "zh-CN"}) + got, err := DecodeAuthRequest(raw) + if err != nil { + t.Fatal(err) + } + if got.Token != "abc" || got.DeviceID != 7 || got.Language != "zh-CN" { + t.Fatalf("%+v", got) + } + if _, err := DecodeAuthRequest([]byte(`{"device_id":1}`)); err == nil { + t.Fatal("empty token") + } + if _, err := DecodeAuthRequest([]byte("not-json")); err == nil { + t.Fatal("garbage") + } +} diff --git a/internal/transport/tcp/server.go b/internal/transport/tcp/server.go index c77abb9..64c1a5f 100644 --- a/internal/transport/tcp/server.go +++ b/internal/transport/tcp/server.go @@ -3,15 +3,19 @@ package tcp import ( "context" "crypto/tls" - "log/slog" + "fmt" + "net" "strconv" "sync" "sync/atomic" "time" "github.com/panjf2000/gnet/v2" + "go.uber.org/zap" - "spark-server/internal/module/auth" + authlogic "spark-server/internal/module/auth/logic" + "spark-server/internal/pkg/ratelimit" + "spark-server/internal/pkg/reqctx" "spark-server/internal/transport/tcp/session" ) @@ -42,7 +46,7 @@ type MsgIDGenerator interface { // Server is the gnet event engine. OnTraffic must not call MySQL, Redis, or deliver fanout. type Server struct { gnet.BuiltinEventEngine - log *slog.Logger + log *zap.Logger pool TaskPool sessions session.Manager eng gnet.Engine @@ -55,14 +59,16 @@ type Server struct { ratePerSec int compressMin int handlerTimeout time.Duration - auth auth.Service + auth authlogic.Service ids MsgIDGenerator router *Router metrics Metrics + authLimit *ratelimit.Limiter conns sync.Map seq atomic.Uint64 ready chan struct{} readyOnce sync.Once + listening atomic.Bool } // Options configure the TCP listener. TLS is terminated at Nginx in production. @@ -80,16 +86,20 @@ type Options struct { RatePerSec int CompressMin int HandlerTimeout time.Duration - Auth auth.Service + Auth authlogic.Service IDs MsgIDGenerator Metrics Metrics + AuthLimiter *ratelimit.Limiter } // NewServer builds a gnet IM listener. Call Start to bind. -func NewServer(log *slog.Logger, pool TaskPool, sessions session.Manager, o *Options) *Server { +func NewServer(log *zap.Logger, pool TaskPool, sessions session.Manager, o *Options) *Server { if o == nil { o = &Options{} } + if log == nil { + log = zap.NewNop() + } applyTCPDefaults(o) s := &Server{ log: log, @@ -106,6 +116,7 @@ func NewServer(log *slog.Logger, pool TaskPool, sessions session.Manager, o *Opt auth: o.Auth, ids: o.IDs, metrics: o.Metrics, + authLimit: o.AuthLimiter, router: NewRouter(), ready: make(chan struct{}), } @@ -114,7 +125,7 @@ func NewServer(log *slog.Logger, pool TaskPool, sessions session.Manager, o *Opt } s.registerDefaultHandlers() if o.TLS != nil { - log.Info("tls cert loaded; terminate at nginx or wrap listener (gnet v2 has no native TLS)") + log.Info("tcp tls cert ignored; terminate IM TLS at nginx (gnet v2 has no native TLS)") } s.opts = gnetOptions(o) return s @@ -164,8 +175,9 @@ func (s *Server) Router() *Router { return s.router } func (s *Server) OnBoot(eng gnet.Engine) gnet.Action { s.eng = eng - s.log.Info("tcp listening", "addr", s.addr) - s.readyOnce.Do(func() { close(s.ready) }) + s.log.Info("tcp listening", zap.String("addr", s.addr)) + s.listening.Store(true) + s.signalReady() return gnet.None } @@ -227,10 +239,33 @@ func (s *Server) OnTraffic(c gnet.Conn) gnet.Action { } func (s *Server) Start() error { - return gnet.Run(s, "tcp://"+s.addr, s.opts...) + err := gnet.Run(s, "tcp://"+s.addr, s.opts...) + s.signalReady() + return err +} + +func (s *Server) WaitReady(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-s.ready: + if s.listening.Load() { + return nil + } + return fmt.Errorf("tcp not listening") + } +} + +func (s *Server) signalReady() { + s.readyOnce.Do(func() { close(s.ready) }) } func (s *Server) Stop(ctx context.Context) error { + if !s.listening.Load() { + s.signalReady() + return nil + } + s.listening.Store(false) return s.eng.Stop(ctx) } @@ -241,6 +276,7 @@ func (s *Server) attach(c gnet.Conn) { remote = addr.String() } sess := session.NewConn(id, remote, s.outboundSize) + sess.SetTraceID(reqctx.NewID()) if !s.proxyProtocol { sess.MarkProxySkipped() } @@ -250,3 +286,18 @@ func (s *Server) attach(c gnet.Conn) { c.SetContext(st) s.conns.Store(id, st) } + +func (s *Server) allowAuthIP(ip string) bool { + if s.authLimit == nil { + return true + } + host := ip + if h, _, err := net.SplitHostPort(ip); err == nil { + host = h + } + if host == "" { + s.metrics.Inc("realip_missing_total") + host = "unknown" + } + return s.authLimit.Allow(host) +} diff --git a/internal/transport/tcp/server_test.go b/internal/transport/tcp/server_test.go index cdcc0d1..c1e0323 100644 --- a/internal/transport/tcp/server_test.go +++ b/internal/transport/tcp/server_test.go @@ -4,13 +4,13 @@ import ( "context" "encoding/binary" "io" - "log/slog" "net" "testing" "time" "spark-server/internal/infrastructure/id" - "spark-server/internal/module/auth" + "spark-server/internal/infrastructure/logger" + authlogic "spark-server/internal/module/auth/logic" "spark-server/internal/transport/tcp/protocol" "spark-server/internal/transport/tcp/session" ) @@ -32,7 +32,7 @@ func TestAuthGateAndGenerateMsgID(t *testing.T) { t.Fatal(err) } srv := startTCP(t, &Options{ - Auth: auth.Static{Token: "secret", UserID: 42, DeviceID: 7}, + Auth: authlogic.Static{Token: "secret", UserID: 42, DeviceID: 7}, IDs: ids, }) c := dial(t, srv.addr) @@ -55,8 +55,25 @@ func TestAuthGateAndGenerateMsgID(t *testing.T) { Body: protocol.EncodeAuthRequest(protocol.AuthRequest{Token: "secret", DeviceID: 7}), }) authed := readFrame(t, c) - if authed.Command != protocol.CmdAuth || len(authed.Body) == 0 { - t.Fatalf("auth reply %+v", authed) + env, err := protocol.DecodeEnvelope(authed.Body) + if err != nil { + t.Fatal(err) + } + if authed.Command != protocol.CmdAuth || env.Code != protocol.CodeOK { + t.Fatalf("auth reply %+v env=%+v", authed, env) + } + + writeFrame(t, c, protocol.Frame{ + Version: protocol.CurrentVersion, Command: protocol.CmdAuth, RequestID: 8, SequenceID: 8, + Body: protocol.EncodeAuthRequest(protocol.AuthRequest{Token: "secret", DeviceID: 99, Language: "en"}), + }) + mismatch := readFrame(t, c) + eb2, err := protocol.DecodeError(mismatch.Body) + if err != nil { + t.Fatal(err) + } + if eb2.Code != protocol.CodeUnauthorized || eb2.Message != "unauthorized" { + t.Fatalf("want device mismatch unauthorized, got %+v", eb2) } writeFrame(t, c, protocol.Frame{ @@ -68,6 +85,48 @@ func TestAuthGateAndGenerateMsgID(t *testing.T) { } } +func TestAuthJWTSameService(t *testing.T) { + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + j, err := authlogic.NewJWT(&authlogic.Options{Secret: "test-secret-test-secret", Issuer: "spark-im", TTL: time.Hour}) + if err != nil { + t.Fatal(err) + } + tok, err := j.Issue(context.Background(), 42, 7) + if err != nil { + t.Fatal(err) + } + srv := startTCP(t, &Options{Auth: j, IDs: ids}) + c := dial(t, srv.addr) + defer c.Close() + writeFrame(t, c, protocol.Frame{ + Version: protocol.CurrentVersion, Command: protocol.CmdAuth, SequenceID: 1, + Body: protocol.EncodeAuthRequest(protocol.AuthRequest{Token: tok.AccessToken, DeviceID: 7}), + }) + authed := readFrame(t, c) + env, err := protocol.DecodeEnvelope(authed.Body) + if err != nil { + t.Fatal(err) + } + if authed.Command != protocol.CmdAuth || env.Code != protocol.CodeOK { + t.Fatalf("jwt auth %+v env=%+v", authed, env) + } + writeFrame(t, c, protocol.Frame{ + Version: protocol.CurrentVersion, Command: protocol.CmdAuth, SequenceID: 2, + Body: protocol.EncodeAuthRequest(protocol.AuthRequest{Token: tok.AccessToken, DeviceID: 99}), + }) + mismatch := readFrame(t, c) + eb, err := protocol.DecodeError(mismatch.Body) + if err != nil { + t.Fatal(err) + } + if eb.Code != protocol.CodeUnauthorized { + t.Fatalf("jwt device mismatch %+v", eb) + } +} + func TestProxyProtocolThenHeartbeat(t *testing.T) { ids, err := id.New(1) if err != nil { @@ -75,7 +134,7 @@ func TestProxyProtocolThenHeartbeat(t *testing.T) { } srv := startTCP(t, &Options{ ProxyProtocol: true, - Auth: auth.Static{Token: "secret", UserID: 42, DeviceID: 7}, + Auth: authlogic.Static{Token: "secret", UserID: 42, DeviceID: 7}, IDs: ids, }) c := dial(t, srv.addr) @@ -122,7 +181,7 @@ func startTCP(t *testing.T, o *Options) *Server { o.IdleTimeout = time.Hour } o.KeepAlive = 30 * time.Second - log := slog.New(slog.NewTextHandler(io.Discard, nil)) + log := logger.Nop() pool := NewTaskPool(4, 32) t.Cleanup(pool.Close) s := NewServer(log, pool, session.NewManager(), o) diff --git a/internal/transport/tcp/session/conn.go b/internal/transport/tcp/session/conn.go index 6e7be06..a7c150f 100644 --- a/internal/transport/tcp/session/conn.go +++ b/internal/transport/tcp/session/conn.go @@ -23,6 +23,7 @@ type Conn struct { userID atomic.Int64 deviceID atomic.Uint64 realIP atomic.Value // string + language atomic.Value // string remote string state atomic.Uint32 lastActive atomic.Int64 @@ -30,6 +31,7 @@ type Conn struct { proxyDone atomic.Bool closed atomic.Bool + traceID string outbound chan *Packet write func([]byte) error closeFn func() error @@ -48,6 +50,7 @@ func NewConn(id, remote string, outboundSize int) *Conn { c.state.Store(StateConnected) c.lastActive.Store(time.Now().UnixNano()) c.realIP.Store("") + c.language.Store("") return c } @@ -61,6 +64,9 @@ func (c *Conn) SetWake(wake func()) { c.wake = wake } +func (c *Conn) SetTraceID(id string) { c.traceID = id } +func (c *Conn) TraceID() string { return c.traceID } + func (c *Conn) ID() string { return c.id } func (c *Conn) UserID() int64 { return c.userID.Load() } func (c *Conn) DeviceID() uint64 { return c.deviceID.Load() } @@ -80,6 +86,15 @@ func (c *Conn) SetRealIP(ip string) { c.proxyDone.Store(true) } +func (c *Conn) Language() string { + v, _ := c.language.Load().(string) + return v +} + +func (c *Conn) SetLanguage(lang string) { + c.language.Store(lang) +} + func (c *Conn) MarkProxySkipped() { c.proxyDone.Store(true) } func (c *Conn) ProxyDone() bool { return c.proxyDone.Load() } @@ -193,10 +208,12 @@ func (c *Conn) DrainOutbound(limit int) []*Packet { func (c *Conn) RequestContext() *reqctx.RequestContext { return &reqctx.RequestContext{ + TraceID: c.traceID, UserID: c.UserID(), DeviceID: c.DeviceID(), ConnectionID: c.id, RemoteAddr: c.remote, RealIP: c.RealIP(), + Language: c.Language(), } } diff --git a/internal/transport/tcp/session/conn_test.go b/internal/transport/tcp/session/conn_test.go index 1d18cae..09564ee 100644 --- a/internal/transport/tcp/session/conn_test.go +++ b/internal/transport/tcp/session/conn_test.go @@ -53,3 +53,19 @@ func TestKickSameDevice(t *testing.T) { t.Fatalf("sessions %+v", sessions) } } + +func TestKickDevice(t *testing.T) { + m := NewManager() + c := NewConn("s1", "10.0.0.1:1", 8) + c.Authenticate(3, 4) + if err := m.Bind(c); err != nil { + t.Fatal(err) + } + m.KickDevice(3, 4) + if c.State() != StateClosed { + t.Fatalf("state %d", c.State()) + } + if len(m.GetUserSessions(3)) != 0 { + t.Fatal("expected empty") + } +} diff --git a/internal/transport/tcp/session/kick.go b/internal/transport/tcp/session/kick.go index a8b8929..935fba4 100644 --- a/internal/transport/tcp/session/kick.go +++ b/internal/transport/tcp/session/kick.go @@ -1,6 +1,7 @@ package session import ( + "spark-server/internal/pkg/i18n" "spark-server/internal/transport/tcp/protocol" ) @@ -20,12 +21,35 @@ func (m *memManager) kickSameDevice(incoming Session) { if existing.DeviceID() != incoming.DeviceID() || existing.ID() == incoming.ID() { continue } + reason := i18n.T(i18n.Parse(existing.Language()), i18n.NotifyKickReplaced, nil) _ = existing.Send(&Packet{ Command: protocol.CmdKick, - Body: protocol.EncodeKick("replaced by new connection"), + Body: protocol.EncodeKick(reason), }) _ = existing.Close() delete(m.byID, existing.ID()) delete(userMap, existing.ID()) } } + +func (m *memManager) KickDevice(userID int64, deviceID uint64) { + m.mu.Lock() + defer m.mu.Unlock() + userMap := m.byUser[userID] + if userMap == nil { + return + } + for id, existing := range userMap { + if existing.DeviceID() != deviceID { + continue + } + reason := i18n.T(i18n.Parse(existing.Language()), i18n.NotifyKickLogout, nil) + _ = existing.Send(&Packet{ + Command: protocol.CmdKick, + Body: protocol.EncodeKick(reason), + }) + _ = existing.Close() + delete(m.byID, id) + delete(userMap, id) + } +} diff --git a/internal/transport/tcp/session/manager.go b/internal/transport/tcp/session/manager.go index 5a9803e..c16f7ab 100644 --- a/internal/transport/tcp/session/manager.go +++ b/internal/transport/tcp/session/manager.go @@ -19,6 +19,7 @@ type Session interface { UserID() int64 DeviceID() uint64 RealIP() string + Language() string Send(packet *Packet) error Close() error } @@ -28,6 +29,7 @@ type Manager interface { Unbind(sessionID string) error Get(sessionID string) (Session, bool) GetUserSessions(userID int64) []Session + KickDevice(userID int64, deviceID uint64) SendToUserExceptDevice(ctx context.Context, userID int64, exceptDeviceID uint64, packet *Packet) error } diff --git a/internal/transport/tcp/taskpool.go b/internal/transport/tcp/taskpool.go index 56f05c3..c3f61b7 100644 --- a/internal/transport/tcp/taskpool.go +++ b/internal/transport/tcp/taskpool.go @@ -3,6 +3,7 @@ package tcp import ( "context" "sync" + "sync/atomic" "spark-server/internal/pkg/errs" ) @@ -11,6 +12,7 @@ import ( type TaskPool interface { Submit(ctx context.Context, task func()) error Close() + Closed() bool } type pool struct { @@ -18,16 +20,22 @@ type pool struct { workers int wg sync.WaitGroup once sync.Once + closed atomic.Bool + onPanic func(any) } func NewTaskPool(workers, queue int) TaskPool { + return NewTaskPoolRecover(workers, queue, nil) +} + +func NewTaskPoolRecover(workers, queue int, onPanic func(any)) TaskPool { if workers < 1 { workers = 1 } if queue < 1 { queue = 1 } - p := &pool{tasks: make(chan func(), queue), workers: workers} + p := &pool{tasks: make(chan func(), queue), workers: workers, onPanic: onPanic} for i := 0; i < workers; i++ { p.wg.Add(1) go p.loop() @@ -38,12 +46,22 @@ func NewTaskPool(workers, queue int) TaskPool { func (p *pool) loop() { defer p.wg.Done() for task := range p.tasks { - if task != nil { - task() - } + p.run(task) } } +func (p *pool) run(task func()) { + if task == nil { + return + } + defer func() { + if rec := recover(); rec != nil && p.onPanic != nil { + p.onPanic(rec) + } + }() + task() +} + func (p *pool) Submit(ctx context.Context, task func()) error { if task == nil { return errs.ErrInvalidArgument @@ -60,7 +78,12 @@ func (p *pool) Submit(ctx context.Context, task func()) error { func (p *pool) Close() { p.once.Do(func() { + p.closed.Store(true) close(p.tasks) p.wg.Wait() }) } + +func (p *pool) Closed() bool { + return p.closed.Load() +} diff --git a/internal/transport/tcp/taskpool_test.go b/internal/transport/tcp/taskpool_test.go index 72eaa52..12a1a3f 100644 --- a/internal/transport/tcp/taskpool_test.go +++ b/internal/transport/tcp/taskpool_test.go @@ -23,6 +23,27 @@ func TestTaskPoolRunsJob(t *testing.T) { } } +func TestTaskPoolRecoversPanic(t *testing.T) { + p := NewTaskPool(1, 4) + defer p.Close() + if err := p.Submit(context.Background(), func() { panic("boom") }); err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if err := p.Submit(context.Background(), func() { close(done) }); err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("worker lost after panic") + } +} + func TestTaskPoolBusy(t *testing.T) { p := NewTaskPool(1, 1) defer p.Close() @@ -41,3 +62,11 @@ func TestTaskPoolBusy(t *testing.T) { t.Fatalf("want busy, got %v", err) } } + +func TestTaskPoolClosed(t *testing.T) { + p := NewTaskPool(1, 1) + p.Close() + if !p.Closed() { + t.Fatal("closed") + } +} diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index c228095..db65a27 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -1,62 +1,62 @@ -- Spark IM V3 initial schema. Message body lives in MySQL; dialog list lives in Redis. CREATE TABLE IF NOT EXISTS conversations ( - id BIGINT PRIMARY KEY, - type TINYINT NOT NULL, - last_msg_id BIGINT NOT NULL DEFAULT 0, - last_msg_at DATETIME(3) NOT NULL, - created_at DATETIME(3) NOT NULL -); + id BIGINT PRIMARY KEY COMMENT 'Snowflake 会话 id,时间有序主键', + type TINYINT NOT NULL COMMENT '会话类型:1=单聊 2=群聊', + last_msg_id BIGINT NOT NULL DEFAULT 0 COMMENT '最新消息 Snowflake id,0 表示尚无消息', + last_msg_at DATETIME(3) NOT NULL COMMENT '最新消息时间(毫秒精度)', + created_at DATETIME(3) NOT NULL COMMENT '会话创建时间' +) COMMENT='会话。单聊/群聊共用一张表;列表与未读在 Redis,不在此表热更新'; CREATE TABLE IF NOT EXISTS p2p_conversations ( - user_small BIGINT NOT NULL, - user_big BIGINT NOT NULL, - conversation_id BIGINT NOT NULL, + user_small BIGINT NOT NULL COMMENT '两人中较小的 user_id,保证同一对用户只有一行', + user_big BIGINT NOT NULL COMMENT '两人中较大的 user_id', + conversation_id BIGINT NOT NULL COMMENT '对应 conversations.id', PRIMARY KEY (user_small, user_big), UNIQUE KEY uk_conversation (conversation_id) -); +) COMMENT='单聊唯一索引。按 (较小 uid, 较大 uid) 定位会话,避免 A-B / B-A 两条记录'; CREATE TABLE IF NOT EXISTS conversation_members ( - conversation_id BIGINT NOT NULL, - user_id BIGINT NOT NULL, - last_read_msg_id BIGINT NOT NULL DEFAULT 0, - joined_at DATETIME(3) NOT NULL, - muted TINYINT NOT NULL DEFAULT 0, + conversation_id BIGINT NOT NULL COMMENT '会话 id', + user_id BIGINT NOT NULL COMMENT '成员 user_id', + last_read_msg_id BIGINT NOT NULL DEFAULT 0 COMMENT '该成员已读到的最大消息 id,未读真相;Redis 丢了可从此重建', + joined_at DATETIME(3) NOT NULL COMMENT '加入会话时间', + muted TINYINT NOT NULL DEFAULT 0 COMMENT '0=未静音 1=免打扰', PRIMARY KEY (conversation_id, user_id), KEY idx_user_id (user_id) -); +) COMMENT='会话成员。热路径会话列表不扫此表;last_read_msg_id 是已读持久化'; CREATE TABLE IF NOT EXISTS messages ( - id BIGINT PRIMARY KEY, - conversation_id BIGINT NOT NULL, - sender_id BIGINT NOT NULL, - client_msg_id VARCHAR(64) NOT NULL, - message_type SMALLINT NOT NULL, - content VARBINARY(65535) NOT NULL, - status TINYINT NOT NULL DEFAULT 1, - created_at DATETIME(3) NOT NULL, + id BIGINT PRIMARY KEY COMMENT 'Snowflake MsgId,主键、排序键、同步游标', + conversation_id BIGINT NOT NULL COMMENT '所属会话 id', + sender_id BIGINT NOT NULL COMMENT '发送者 user_id', + client_msg_id VARCHAR(64) NOT NULL COMMENT '客户端消息 id,与 sender+会话 组成幂等键', + message_type SMALLINT NOT NULL COMMENT '消息类型:1文本 2图片 3语音 4视频 5表情 6GIF 7文件 8位置 9系统', + content BLOB NOT NULL COMMENT '序列化后的消息体(JSON 字节),禁止存原始 string;媒体只存对象存储 key', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 2=已撤回 3=审核隐藏', + created_at DATETIME(3) NOT NULL COMMENT '服务端入库时间', UNIQUE KEY uk_sender_conv_client_msg (sender_id, conversation_id, client_msg_id), KEY idx_conversation_id (conversation_id, id) -); +) COMMENT='消息正文。PK=MsgId;content 为序列化 BLOB,不存媒体二进制'; CREATE TABLE IF NOT EXISTS mailbox_events ( - id BIGINT PRIMARY KEY, - user_id BIGINT NOT NULL, - device_id BIGINT NOT NULL, - seq BIGINT NOT NULL, - event_type SMALLINT NOT NULL, - conversation_id BIGINT NULL, - payload VARBINARY(4096), - created_at DATETIME(3) NOT NULL, + id BIGINT PRIMARY KEY COMMENT 'Snowflake 事件 id', + user_id BIGINT NOT NULL COMMENT '接收用户', + device_id BIGINT NOT NULL COMMENT '目标设备;0 表示该用户全部设备', + seq BIGINT NOT NULL COMMENT '该 user+device 上单调递增的邮箱序号', + event_type SMALLINT NOT NULL COMMENT '事件类型:撤回/踢人/成员变更/好友/漏推消息等,不含成功在线推送的新消息', + conversation_id BIGINT NULL COMMENT '相关会话,可空', + payload VARBINARY(4096) COMMENT '控制事件载荷(会话 id+消息 id 等),不含消息正文', + created_at DATETIME(3) NOT NULL COMMENT '写入时间', UNIQUE KEY uk_user_device_seq (user_id, device_id, seq), KEY idx_user_device (user_id, device_id, seq) -); +) COMMENT='失败邮箱与控制事件。成功在线推送的新消息不写此表'; CREATE TABLE IF NOT EXISTS device_sync_cursors ( - user_id BIGINT NOT NULL, - device_id BIGINT NOT NULL, - mailbox_seq BIGINT NOT NULL DEFAULT 0, - dialog_score BIGINT NOT NULL DEFAULT 0, - updated_at DATETIME(3) NOT NULL, + user_id BIGINT NOT NULL COMMENT '用户 id', + device_id BIGINT NOT NULL COMMENT '设备 id', + mailbox_seq BIGINT NOT NULL DEFAULT 0 COMMENT '该设备已同步到的 mailbox_events.seq', + dialog_score BIGINT NOT NULL DEFAULT 0 COMMENT '会话列表 Redis ZSET 游标', + updated_at DATETIME(3) NOT NULL COMMENT '游标更新时间', PRIMARY KEY (user_id, device_id) -); +) COMMENT='每设备同步游标。mailbox_seq 对邮箱,dialog_score 对会话列表'; diff --git a/migrations/0002_users.sql b/migrations/0002_users.sql new file mode 100644 index 0000000..27a6b17 --- /dev/null +++ b/migrations/0002_users.sql @@ -0,0 +1,41 @@ +-- Users: Snowflake PK (not AUTO_INCREMENT), public spark_id, email map, login devices. + +CREATE TABLE IF NOT EXISTS users ( + id BIGINT PRIMARY KEY COMMENT 'Snowflake 用户 id(uid),时间有序,非自增', + spark_id VARCHAR(32) NOT NULL COMMENT '对外展示的账号,用户可改,全局唯一', + nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称', + avatar VARCHAR(512) NOT NULL DEFAULT '' COMMENT '头像对象存储 key 或 URL,不存图片二进制', + qrcode VARCHAR(512) NOT NULL DEFAULT '' COMMENT '个人二维码对象存储 key 或 URL,不存图片二进制', + birthday DATE NULL COMMENT '生日,未填为空', + gender TINYINT NOT NULL DEFAULT 0 COMMENT '0=未知 1=男 2=女', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 0=禁用', + last_login_at DATETIME(3) NULL COMMENT '最近一次登录时间', + last_active_at DATETIME(3) NULL COMMENT '最近一次活跃时间(登录、心跳、发消息等)', + created_at DATETIME(3) NOT NULL COMMENT '注册时间(create_time)', + updated_at DATETIME(3) NOT NULL COMMENT '资料最近更新时间(update_time)', + UNIQUE KEY uk_spark_id (spark_id) +) COMMENT='用户账号。主键 Snowflake 即 uid;登录身份在 user_emails,设备在 user_devices'; + +CREATE TABLE IF NOT EXISTS user_emails ( + email VARCHAR(255) NOT NULL COMMENT '规范化后的小写邮箱,登录账号', + user_id BIGINT NOT NULL COMMENT '绑定的 users.id,一对一', + created_at DATETIME(3) NOT NULL COMMENT '绑定时间', + PRIMARY KEY (email), + UNIQUE KEY uk_user_id (user_id) +) COMMENT='邮箱与用户的一对一映射。登录用邮箱查此表再读 users'; + +CREATE TABLE IF NOT EXISTS user_devices ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增,仅内部用;业务唯一键是 (user_id, device_id)', + user_id BIGINT NOT NULL COMMENT '所属用户', + device_id BIGINT UNSIGNED NOT NULL COMMENT '客户端设备 id,写入 JWT did', + platform VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'ios/android/web 等', + device_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '设备展示名', + app_version VARCHAR(32) NOT NULL DEFAULT '' COMMENT '最近一次登录的客户端版本', + language VARCHAR(16) NOT NULL DEFAULT '' COMMENT '最近一次登录语言,如 zh-CN', + last_ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '最近一次登录的 PROXY RealIP,禁止用环回地址', + last_login_at DATETIME(3) NOT NULL COMMENT '最近一次登录时间', + created_at DATETIME(3) NOT NULL COMMENT '该设备首次登录时间', + PRIMARY KEY (id), + UNIQUE KEY uk_user_device (user_id, device_id), + KEY idx_user_id (user_id) +) COMMENT='登录过的设备。同 user+device 覆盖更新;踢人按 device_id'; diff --git a/migrations/0003_table_comments.sql b/migrations/0003_table_comments.sql new file mode 100644 index 0000000..13f5137 --- /dev/null +++ b/migrations/0003_table_comments.sql @@ -0,0 +1,82 @@ +-- Add table/column comments for databases created before 0001/0002 carried COMMENT clauses. + +ALTER TABLE conversations COMMENT = '会话。单聊/群聊共用一张表;列表与未读在 Redis,不在此表热更新'; +ALTER TABLE conversations + MODIFY COLUMN id BIGINT NOT NULL COMMENT 'Snowflake 会话 id,时间有序主键', + MODIFY COLUMN type TINYINT NOT NULL COMMENT '会话类型:1=单聊 2=群聊', + MODIFY COLUMN last_msg_id BIGINT NOT NULL DEFAULT 0 COMMENT '最新消息 Snowflake id,0 表示尚无消息', + MODIFY COLUMN last_msg_at DATETIME(3) NOT NULL COMMENT '最新消息时间(毫秒精度)', + MODIFY COLUMN created_at DATETIME(3) NOT NULL COMMENT '会话创建时间'; + +ALTER TABLE p2p_conversations COMMENT = '单聊唯一索引。按 (较小 uid, 较大 uid) 定位会话,避免 A-B / B-A 两条记录'; +ALTER TABLE p2p_conversations + MODIFY COLUMN user_small BIGINT NOT NULL COMMENT '两人中较小的 user_id,保证同一对用户只有一行', + MODIFY COLUMN user_big BIGINT NOT NULL COMMENT '两人中较大的 user_id', + MODIFY COLUMN conversation_id BIGINT NOT NULL COMMENT '对应 conversations.id'; + +ALTER TABLE conversation_members COMMENT = '会话成员。热路径会话列表不扫此表;last_read_msg_id 是已读持久化'; +ALTER TABLE conversation_members + MODIFY COLUMN conversation_id BIGINT NOT NULL COMMENT '会话 id', + MODIFY COLUMN user_id BIGINT NOT NULL COMMENT '成员 user_id', + MODIFY COLUMN last_read_msg_id BIGINT NOT NULL DEFAULT 0 COMMENT '该成员已读到的最大消息 id,未读真相;Redis 丢了可从此重建', + MODIFY COLUMN joined_at DATETIME(3) NOT NULL COMMENT '加入会话时间', + MODIFY COLUMN muted TINYINT NOT NULL DEFAULT 0 COMMENT '0=未静音 1=免打扰'; + +ALTER TABLE messages COMMENT = '消息正文。PK=MsgId;content 为序列化 BLOB,不存媒体二进制'; +ALTER TABLE messages + MODIFY COLUMN id BIGINT NOT NULL COMMENT 'Snowflake MsgId,主键、排序键、同步游标', + MODIFY COLUMN conversation_id BIGINT NOT NULL COMMENT '所属会话 id', + MODIFY COLUMN sender_id BIGINT NOT NULL COMMENT '发送者 user_id', + MODIFY COLUMN client_msg_id VARCHAR(64) NOT NULL COMMENT '客户端消息 id,与 sender+会话 组成幂等键', + MODIFY COLUMN message_type SMALLINT NOT NULL COMMENT '消息类型:1文本 2图片 3语音 4视频 5表情 6GIF 7文件 8位置 9系统', + MODIFY COLUMN content BLOB NOT NULL COMMENT '序列化后的消息体(JSON 字节),禁止存原始 string;媒体只存对象存储 key', + MODIFY COLUMN status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 2=已撤回 3=审核隐藏', + MODIFY COLUMN created_at DATETIME(3) NOT NULL COMMENT '服务端入库时间'; + +ALTER TABLE mailbox_events COMMENT = '失败邮箱与控制事件。成功在线推送的新消息不写此表'; +ALTER TABLE mailbox_events + MODIFY COLUMN id BIGINT NOT NULL COMMENT 'Snowflake 事件 id', + MODIFY COLUMN user_id BIGINT NOT NULL COMMENT '接收用户', + MODIFY COLUMN device_id BIGINT NOT NULL COMMENT '目标设备;0 表示该用户全部设备', + MODIFY COLUMN seq BIGINT NOT NULL COMMENT '该 user+device 上单调递增的邮箱序号', + MODIFY COLUMN event_type SMALLINT NOT NULL COMMENT '事件类型:撤回/踢人/成员变更/好友/漏推消息等,不含成功在线推送的新消息', + MODIFY COLUMN conversation_id BIGINT NULL COMMENT '相关会话,可空', + MODIFY COLUMN payload VARBINARY(4096) COMMENT '控制事件载荷(会话 id+消息 id 等),不含消息正文', + MODIFY COLUMN created_at DATETIME(3) NOT NULL COMMENT '写入时间'; + +ALTER TABLE device_sync_cursors COMMENT = '每设备同步游标。mailbox_seq 对邮箱,dialog_score 对会话列表'; +ALTER TABLE device_sync_cursors + MODIFY COLUMN user_id BIGINT NOT NULL COMMENT '用户 id', + MODIFY COLUMN device_id BIGINT NOT NULL COMMENT '设备 id', + MODIFY COLUMN mailbox_seq BIGINT NOT NULL DEFAULT 0 COMMENT '该设备已同步到的 mailbox_events.seq', + MODIFY COLUMN dialog_score BIGINT NOT NULL DEFAULT 0 COMMENT '会话列表 Redis ZSET 游标', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL COMMENT '游标更新时间'; + +ALTER TABLE users COMMENT = '用户账号。主键 Snowflake;登录身份在 user_emails,设备在 user_devices'; +ALTER TABLE users + MODIFY COLUMN id BIGINT NOT NULL COMMENT 'Snowflake 用户 id(uid),时间有序,非自增', + MODIFY COLUMN spark_id VARCHAR(32) NOT NULL COMMENT '对外展示的账号,用户可改,全局唯一', + MODIFY COLUMN nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称', + MODIFY COLUMN avatar VARCHAR(512) NOT NULL DEFAULT '' COMMENT '头像对象存储 key 或 URL,不存图片二进制', + MODIFY COLUMN status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 0=禁用', + MODIFY COLUMN created_at DATETIME(3) NOT NULL COMMENT '注册时间', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL COMMENT '资料最近更新时间'; + +ALTER TABLE user_emails COMMENT = '邮箱与用户的一对一映射。登录用邮箱查此表再读 users'; +ALTER TABLE user_emails + MODIFY COLUMN email VARCHAR(255) NOT NULL COMMENT '规范化后的小写邮箱,登录账号', + MODIFY COLUMN user_id BIGINT NOT NULL COMMENT '绑定的 users.id,一对一', + MODIFY COLUMN created_at DATETIME(3) NOT NULL COMMENT '绑定时间'; + +ALTER TABLE user_devices COMMENT = '登录过的设备。同 user+device 覆盖更新;踢人按 device_id'; +ALTER TABLE user_devices + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增,仅内部用;业务唯一键是 (user_id, device_id)', + MODIFY COLUMN user_id BIGINT NOT NULL COMMENT '所属用户', + MODIFY COLUMN device_id BIGINT UNSIGNED NOT NULL COMMENT '客户端设备 id,写入 JWT did', + MODIFY COLUMN platform VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'ios/android/web 等', + MODIFY COLUMN device_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '设备展示名', + MODIFY COLUMN app_version VARCHAR(32) NOT NULL DEFAULT '' COMMENT '最近一次登录的客户端版本', + MODIFY COLUMN language VARCHAR(16) NOT NULL DEFAULT '' COMMENT '最近一次登录语言,如 zh-CN', + MODIFY COLUMN last_ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '最近一次登录的 PROXY RealIP,禁止用环回地址', + MODIFY COLUMN last_login_at DATETIME(3) NOT NULL COMMENT '最近一次登录时间', + MODIFY COLUMN created_at DATETIME(3) NOT NULL COMMENT '该设备首次登录时间'; diff --git a/migrations/0004_user_devices_autoincrement.sql b/migrations/0004_user_devices_autoincrement.sql new file mode 100644 index 0000000..b9cf0f5 --- /dev/null +++ b/migrations/0004_user_devices_autoincrement.sql @@ -0,0 +1,5 @@ +-- user_devices.id is a table-local AUTO_INCREMENT row key, not Snowflake. +-- Business identity remains (user_id, device_id). Existing Snowflake values stay; new rows continue from MAX(id)+1. + +ALTER TABLE user_devices + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增,仅内部用;业务唯一键是 (user_id, device_id)'; diff --git a/migrations/0005_user_profile.sql b/migrations/0005_user_profile.sql new file mode 100644 index 0000000..5750814 --- /dev/null +++ b/migrations/0005_user_profile.sql @@ -0,0 +1,9 @@ +-- Profile fields on users. One ADD per statement (stock MySQL has no ADD COLUMN IF NOT EXISTS). +-- Re-running migrate ignores duplicate-column errors. + +ALTER TABLE users ADD COLUMN qrcode VARCHAR(512) NOT NULL DEFAULT '' COMMENT '个人二维码对象存储 key 或 URL,不存图片二进制'; +ALTER TABLE users ADD COLUMN birthday DATE NULL COMMENT '生日,未填为空'; +ALTER TABLE users ADD COLUMN gender TINYINT NOT NULL DEFAULT 0 COMMENT '0=未知 1=男 2=女'; +ALTER TABLE users ADD COLUMN last_login_at DATETIME(3) NULL COMMENT '最近一次登录时间'; +ALTER TABLE users ADD COLUMN last_active_at DATETIME(3) NULL COMMENT '最近一次活跃时间(登录、心跳、发消息等)'; +ALTER TABLE users COMMENT = '用户账号。主键 Snowflake 即 uid;登录身份在 user_emails,设备在 user_devices'; diff --git a/migrations/0006_user_login_logs.sql b/migrations/0006_user_login_logs.sql new file mode 100644 index 0000000..7348598 --- /dev/null +++ b/migrations/0006_user_login_logs.sql @@ -0,0 +1,18 @@ +-- user_login_logs: one row per successful login. user_devices.platform is the device type. + +CREATE TABLE IF NOT EXISTS user_login_logs ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增', + user_id BIGINT NOT NULL COMMENT '登录用户', + device_id BIGINT UNSIGNED NOT NULL COMMENT '客户端设备 id', + device_type VARCHAR(16) NOT NULL COMMENT '设备类型:ios/android/harmony/windows/macos/web', + device_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '设备展示名', + app_version VARCHAR(32) NOT NULL DEFAULT '' COMMENT '客户端版本', + language VARCHAR(16) NOT NULL DEFAULT '' COMMENT '登录语言', + ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'PROXY RealIP', + created_at DATETIME(3) NOT NULL COMMENT '本次登录时间', + PRIMARY KEY (id), + KEY idx_user_created (user_id, created_at) +) COMMENT='用户登录记录。每次成功登录追加一行;当前设备以 user_devices 为准'; + +ALTER TABLE user_devices + MODIFY COLUMN platform VARCHAR(32) NOT NULL DEFAULT '' COMMENT '设备类型:ios/android/harmony/windows/macos/web'; diff --git a/migrations/0007_friends.sql b/migrations/0007_friends.sql new file mode 100644 index 0000000..fc7f2e5 --- /dev/null +++ b/migrations/0007_friends.sql @@ -0,0 +1,50 @@ +-- Bidirectional friends, one pending request per pair, unidirectional block/remark/report. + +CREATE TABLE IF NOT EXISTS friend_relations ( + user_small BIGINT NOT NULL COMMENT '两人中较小的 user_id,保证同一对用户只有一行', + user_big BIGINT NOT NULL COMMENT '两人中较大的 user_id', + created_at DATETIME(3) NOT NULL COMMENT '成为好友时间', + PRIMARY KEY (user_small, user_big), + KEY idx_user_big (user_big) +) COMMENT='双向好友。无单向好友;删除任一方即解除双方关系'; + +CREATE TABLE IF NOT EXISTS friend_requests ( + id BIGINT PRIMARY KEY COMMENT 'Snowflake 申请 id', + from_user BIGINT NOT NULL COMMENT '申请人 user_id', + to_user BIGINT NOT NULL COMMENT '被申请人 user_id', + source TINYINT NOT NULL COMMENT '来源:1=二维码 2=邮箱 3=spark_id', + reason VARCHAR(128) NOT NULL DEFAULT '' COMMENT '申请理由', + remark VARCHAR(64) NOT NULL DEFAULT '' COMMENT '申请人对对方的备注,同意后写入 friend_remarks', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1=待处理 2=已同意 3=已拒绝', + created_at DATETIME(3) NOT NULL COMMENT '首次申请时间', + updated_at DATETIME(3) NOT NULL COMMENT '最近一次申请或处理时间', + UNIQUE KEY uk_from_to (from_user, to_user), + KEY idx_to_status (to_user, status, updated_at) +) COMMENT='好友申请。同一对 from→to 只保留一行,重复发起覆盖理由与备注'; + +CREATE TABLE IF NOT EXISTS friend_remarks ( + user_id BIGINT NOT NULL COMMENT '备注所属用户', + friend_id BIGINT NOT NULL COMMENT '被备注的用户', + remark VARCHAR(64) NOT NULL DEFAULT '' COMMENT '备注名,仅对备注者可见', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (user_id, friend_id) +) COMMENT='好友备注。单向,解除好友时删除双方备注'; + +CREATE TABLE IF NOT EXISTS user_blocks ( + user_id BIGINT NOT NULL COMMENT '拉黑人 user_id', + blocked_id BIGINT NOT NULL COMMENT '被拉黑人 user_id', + created_at DATETIME(3) NOT NULL COMMENT '拉黑时间', + PRIMARY KEY (user_id, blocked_id), + KEY idx_blocked (blocked_id) +) COMMENT='拉黑。单向;拉黑时解除好友关系'; + +CREATE TABLE IF NOT EXISTS friend_reports ( + id BIGINT PRIMARY KEY COMMENT 'Snowflake 投诉 id', + user_id BIGINT NOT NULL COMMENT '投诉人 user_id', + target_id BIGINT NOT NULL COMMENT '被投诉人 user_id', + reason TINYINT NOT NULL COMMENT '投诉类型:1=垃圾 2=骚扰 3=不实身份 4=其他', + detail VARCHAR(512) NOT NULL DEFAULT '' COMMENT '投诉说明', + created_at DATETIME(3) NOT NULL COMMENT '投诉时间', + KEY idx_user_created (user_id, created_at), + KEY idx_target (target_id) +) COMMENT='好友投诉。仅记录,不自动拉黑'; diff --git a/migrations/0008_favorites.sql b/migrations/0008_favorites.sql new file mode 100644 index 0000000..95e823f --- /dev/null +++ b/migrations/0008_favorites.sql @@ -0,0 +1,13 @@ +-- 用户收藏。媒体对象复制到 perm/fav,不引用 chat/ 原 key;源对象过期则无法再收藏。 + +CREATE TABLE IF NOT EXISTS favorites ( + id BIGINT PRIMARY KEY COMMENT 'Snowflake 收藏 id', + user_id BIGINT NOT NULL COMMENT '收藏者 user_id', + msg_id BIGINT NOT NULL COMMENT '原消息 Snowflake id,仅作来源;撤回不影响收藏', + conversation_id BIGINT NOT NULL COMMENT '原会话 id,仅作来源', + message_type SMALLINT NOT NULL COMMENT '消息类型,与 messages.message_type 一致', + content BLOB NOT NULL COMMENT '收藏快照(JSON 字节);媒体 key 已复制到 perm/fav/', + created_at DATETIME(3) NOT NULL COMMENT '收藏时间', + UNIQUE KEY uk_user_msg (user_id, msg_id), + KEY idx_user_id (user_id, id) +) COMMENT='用户收藏。媒体复制到 perm/fav;chat/ 源过期后收藏失败,不提供补传'; diff --git a/migrations/0009_groups.sql b/migrations/0009_groups.sql new file mode 100644 index 0000000..0fd3266 --- /dev/null +++ b/migrations/0009_groups.sql @@ -0,0 +1,29 @@ +-- Group id is a table-local AUTO_INCREMENT 群号. Chat / sync still use Snowflake conversations.id. + +CREATE TABLE IF NOT EXISTS `groups` ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '群号,表内自增,对外群 ID', + conversation_id BIGINT NOT NULL COMMENT '对应 conversations.id(Snowflake),发消息与同步用', + name VARCHAR(32) NOT NULL COMMENT '群名称', + avatar VARCHAR(512) NOT NULL DEFAULT '' COMMENT '群头像对象存储 key,空表示未设置', + owner_id BIGINT NOT NULL COMMENT '群主 user_id', + muted_all TINYINT NOT NULL DEFAULT 0 COMMENT '0=未全员禁言 1=全员禁言(群主/管理员仍可发言)', + member_count INT NOT NULL DEFAULT 0 COMMENT '当前成员数,含群主', + status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 2=已解散', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL COMMENT '资料或成员变更时间', + PRIMARY KEY (id), + UNIQUE KEY uk_conversation (conversation_id), + KEY idx_owner_id (owner_id), + KEY idx_status_updated (status, updated_at) +) COMMENT='群资料。id 是自增群号;会话列表与未读仍按 conversation_id 走 Redis'; + +CREATE TABLE IF NOT EXISTS group_members ( + group_id BIGINT NOT NULL COMMENT '群号,groups.id', + user_id BIGINT NOT NULL COMMENT '成员 user_id', + role TINYINT NOT NULL DEFAULT 3 COMMENT '1=群主 2=管理员 3=普通成员', + nickname VARCHAR(32) NOT NULL DEFAULT '' COMMENT '群内昵称,空则用用户昵称', + speak_muted TINYINT NOT NULL DEFAULT 0 COMMENT '0=未禁言 1=被单独禁言', + joined_at DATETIME(3) NOT NULL COMMENT '入群时间', + PRIMARY KEY (group_id, user_id), + KEY idx_user_id (user_id) +) COMMENT='群成员与角色。热路径 MemberIDs 可短缓存;免打扰在 conversation_members';