diff --git a/im/.deploy/admin.sha b/im/.deploy/admin.sha new file mode 100644 index 0000000..221b1ca --- /dev/null +++ b/im/.deploy/admin.sha @@ -0,0 +1 @@ +57dce2e3bd928effae6f9684d54fc4f44a1865a1ee257f92d3912bc96e9ea5fd diff --git a/im/.deploy/api.gz b/im/.deploy/api.gz new file mode 100644 index 0000000..db60921 Binary files /dev/null and b/im/.deploy/api.gz differ diff --git a/im/.deploy/application-20260831-120612/__pycache__/remote_exec.cpython-311.pyc b/im/.deploy/application-20260831-120612/__pycache__/remote_exec.cpython-311.pyc new file mode 100644 index 0000000..bd921ea Binary files /dev/null and b/im/.deploy/application-20260831-120612/__pycache__/remote_exec.cpython-311.pyc differ diff --git a/im/.deploy/application-20260831-120612/backend/cmd/server/main.go b/im/.deploy/application-20260831-120612/backend/cmd/server/main.go new file mode 100644 index 0000000..3c528ee --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/cmd/server/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "log" + + "github.com/example/xingyu/internal/app" +) + +func main() { + service, err := app.New(app.LoadConfig()) + if err != nil { + log.Fatal(err) + } + defer service.Close() + if err := service.Seed(); err != nil { + log.Fatal(err) + } + service.Run() +} diff --git a/im/.deploy/application-20260831-120612/backend/docs/admin-create-user.md b/im/.deploy/application-20260831-120612/backend/docs/admin-create-user.md new file mode 100644 index 0000000..1292631 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/docs/admin-create-user.md @@ -0,0 +1,26 @@ +# 管理端新增客户端用户 + +入口:管理端 → 用户管理 → 新增用户。必填中国大陆手机号、昵称和初始密码;性别、城市、简介可选。创建后列表自动定位到新用户,可继续编辑资料。 + +- 账号默认为正常、正式用户、普通会员状态、未认证;创建时不发短信、不创建登录会话。 +- 初始密码必填,但不限制长度或字符组合,纯数字、纯字母、中文等均可。账号可在 App/H5 使用手机号和初始密码登录;请通过安全渠道交付密码,并提醒用户登录后修改。 +- 手机号通过唯一索引防止重复(包括已停用或尚未释放手机号的账号),使用 AES-GCM 加密保存;密码以 bcrypt 哈希保存,超过 72 字节的密码使用 Argon2id,兼容已有账号。通用 HTTP 请求大小限制仍然生效。 +- 账号、资料、隐私设置、通知设置和审计日志在同一事务内创建;任一写入失败会回滚。 + +## 接口与权限 + +`POST /admin/v1/users`,必须携带有效管理员令牌,并具备 `users:create` 权限。请求字段:`phone`、`password`、`nickname`,可选 `gender`(0/1/2)、`city`、`bio`。不接受设置会员、认证、测试标记或账号状态的额外字段。 + +成功返回通用响应中的 `data: { id, publicId }`,不返回密码或用户令牌。参数错误返回 HTTP 400,手机号重复返回 409,权限不足返回 403,数据库错误返回 500。 + +审计动作是 `create_user`,包含操作管理员、目标用户、星遇号、昵称、来源及操作 IP,不保存初始密码或完整手机号。 + +## 发布 + +1. 使用项目现有迁移流程执行 `backend/migrations/029_admin_create_users.sql`(此前的迁移必须已执行)。该迁移新增权限,并仅默认授予内置超级管理员角色。 +2. 发布更新后的后端和管理端构建。管理端构建命令:在 `admin` 目录执行 `pnpm --filter @vben/web-ele build`。 +3. 管理员重新登录以刷新权限;其他运营角色可在“管理员与权限”中按需勾选“新增客户端用户”,并保留“查看用户”以访问用户列表。 + +## 验证 + +`go test ./internal/app -run TestAdminCreateUser -count=1` 覆盖真实路由的认证和权限、参数校验、重复手机号保护、敏感数据保护、事务回滚、列表查询及密码登录。测试使用内存 SQL 适配器,不接触线上账号;真实数据库迁移和部署后仍需验收。 diff --git a/im/.deploy/application-20260831-120612/backend/docs/password-policy.md b/im/.deploy/application-20260831-120612/backend/docs/password-policy.md new file mode 100644 index 0000000..838f89f --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/docs/password-policy.md @@ -0,0 +1,17 @@ +# 密码输入规则 + +管理端修改密码、管理员创建/重置密码、管理端新增客户端用户,以及客户端注册、找回和修改密码统一取消长度和字符组合要求。允许单个字符、纯数字、纯字母、中文等,不展示密码强度条,也不限制新密码必须不同于旧密码。 + +保留密码必填、两次输入一致、旧密码核验,以及登录鉴权、短信验证码、接口权限和请求频率限制。修改密码后原有会话继续失效。编辑管理员资料时密码留空仍表示不修改密码。 + +## 存储兼容 + +原有 bcrypt 密码不需要迁移。72 字节以内的密码继续使用 bcrypt;更长的密码使用带随机盐的 Argon2id(19 MiB 内存、2 次迭代、1 个线程),不截断输入。验证时根据哈希前缀选择算法,只接受已支持的参数,避免损坏数据触发不受控的内存分配。已有 `password_hash VARCHAR(255)` 字段可容纳这两种格式。 + +使用 Argon2id 是为了避免移除输入上限后触发 bcrypt 的技术限制,参数参考 [OWASP 密码存储建议](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html),实现使用 [Go Argon2 包](https://pkg.go.dev/golang.org/x/crypto/argon2)。通用 HTTP 请求体积限制保留,不属于密码格式规则。 + +## 验证与发布 + +后端测试覆盖简单密码及长密码的创建和登录、管理员修改密码、旧密码验证、旧会话撤销失败时回滚、已有 bcrypt 密码兼容、长密码尾部不同不能登录、随机盐和损坏哈希处理。 + +本次修改无需新增数据库迁移。需同步发布后端和管理端;App 用户需安装新包,H5 更新构建。长密码一旦保存为 Argon2id,回退后端到旧版本会导致这些账号无法登录,回退时应保留新哈希的验证支持。 diff --git a/im/.deploy/application-20260831-120612/backend/docs/social.api b/im/.deploy/application-20260831-120612/backend/docs/social.api new file mode 100644 index 0000000..01db08a --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/docs/social.api @@ -0,0 +1,182 @@ +syntax = "v1" + +info ( + title: "星遇社交平台 API" + desc: "账户、发现、动态、会员与 IM REST 接口定义" + author: "Xingyu" + version: "1.0" +) + +type LoginRequest { Phone string `json:"phone"`; Password string `json:"password"`; DeviceID string `json:"deviceId"` } +type LoginResponse { AccessToken string `json:"accessToken"`; RefreshToken string `json:"refreshToken"`; ExpiresIn int64 `json:"expiresIn"`; UserID int64 `json:"userId"` } +type CreatePostRequest { Content string `json:"content"`; Media []string `json:"media"`; Location string `json:"location"`; Visibility int `json:"visibility"` } +type SMSRequest { Phone string `json:"phone"`; Scene string `json:"scene"` } +type ResetPasswordRequest { Phone string `json:"phone"`; Code string `json:"code"`; Password string `json:"password"` } +type SendMessageRequest { ClientMsgID string `json:"clientMsgId"`; Type int `json:"type"`; Content map[string]string `json:"content"` } +type UserVerificationRequest { Type string `json:"type"`; RealName string `json:"realName"`; DocumentNumber string `json:"documentNumber"`; Evidence []string `json:"evidence"` } +type AdminProfileRequest { Phone string `json:"phone"`; Nickname string `json:"nickname"`; Avatar string `json:"avatar"`; Cover string `json:"cover"`; Gender int `json:"gender"`; Birthday string `json:"birthday"`; Height int `json:"height"`; CityCode string `json:"cityCode"`; City string `json:"city"`; Occupation string `json:"occupation"`; Bio string `json:"bio"` } +type VerificationRequest { Type string `json:"type"`; Status string `json:"status"`; RealName string `json:"realName"`; DocumentMask string `json:"documentMask"`; Remark string `json:"remark"` } +type MembershipGrantRequest { Operation string `json:"operation"`; PlanID int64 `json:"planId"`; ExpiresAt string `json:"expiresAt"`; Reason string `json:"reason"` } +type PasswordResetByAdminRequest { NewPassword string `json:"newPassword"` } +type AdminCreateUserRequest { Phone string `json:"phone"`; Password string `json:"password"`; Nickname string `json:"nickname"`; Gender int `json:"gender,optional"`; City string `json:"city,optional"`; Bio string `json:"bio,optional"` } +type SanctionRequest { Type string `json:"type"`; Reason string `json:"reason"`; ExpiresAt string `json:"expiresAt"` } +type MembershipPlanRequest { Code string `json:"code"`; Name string `json:"name"`; Level int `json:"level"`; DurationDays int `json:"durationDays"`; DailyActiveChatLimit int `json:"dailyActiveChatLimit"`; DailyLikeLimit int `json:"dailyLikeLimit"`; CanViewVisitors bool `json:"canViewVisitors"`; CanInvisibleVisit bool `json:"canInvisibleVisit"`; RecommendationWeight int `json:"recommendationWeight"`; PriceCent int `json:"priceCent"`; OriginalPriceCent int `json:"originalPriceCent"`; Status int `json:"status"`; SortOrder int `json:"sortOrder"` } +type AdminOrderUpdateRequest { ProductID int64 `json:"productId"`; AmountCent int `json:"amountCent"`; Channel string `json:"channel"`; Status string `json:"status"` } +type AdminAccountRequest { Username string `json:"username"`; DisplayName string `json:"displayName"`; Password string `json:"password"`; Status int `json:"status"`; RoleIDs []int64 `json:"roleIds"` } +type AdminRoleRequest { Code string `json:"code"`; Name string `json:"name"`; Description string `json:"description"`; Status int `json:"status"`; Permissions []string `json:"permissions"` } +type MessageModerationRequest { Action string `json:"action"`; Reason string `json:"reason"` } +type FeedbackHandleRequest { Status string `json:"status"`; Reply string `json:"reply"` } +type AccountClosureHandleRequest { Action string `json:"action"`; Reason string `json:"reason"` } +type AppVersionRequest { Platform string `json:"platform"`; Version string `json:"version"`; BuildNumber int `json:"buildNumber"`; ForceUpdate bool `json:"forceUpdate"`; DownloadURL string `json:"downloadUrl"`; ReleaseNotes string `json:"releaseNotes"`; Status int `json:"status"` } + +@server (prefix: /api/v1) +service social-api { + @handler Login + post /auth/login/password (LoginRequest) returns (LoginResponse) + @handler SendSMS + post /auth/sms/send (SMSRequest) + @handler ResetPassword + post /auth/password/reset (ResetPasswordRequest) + @handler Me + get /me + @handler MyVerification + get /me/verification + @handler SubmitVerification + post /me/verification (UserVerificationRequest) + @handler SearchUsers + get /users/search + @handler Following + get /me/following + @handler Followers + get /me/followers + @handler Visitors + get /me/visitors + @handler Privacy + get /me/privacy + @handler NotificationSettings + get /me/notification-settings + @handler UpdateNotificationSettings + put /me/notification-settings + @handler MyDevices + get /me/devices + @handler MyFeedback + get /me/feedback + @handler SubmitFeedback + post /me/feedback + @handler AccountClosure + get /me/account-closure + @handler MyOrders + get /me/orders + @handler MyReports + get /me/reports + @handler Discover + get /discover/recommendations + @handler Nearby + get /nearby/users + @handler Feed + get /feed + @handler CreatePost + post /posts (CreatePostRequest) + @handler PostDetail + get /posts/:id + @handler Comments + get /posts/:id/comments + @handler Conversations + get /im/conversations + @handler Messages + get /im/conversations/:id/messages + @handler SendMessage + post /im/conversations/:id/messages (SendMessageRequest) + @handler RecallMessage + post /im/messages/:id/recall + @handler MembershipPlans + get /membership/plans + @handler PaymentChannels + get /payment/channels + @handler CreateOrder + post /orders + @handler PayOrder + post /orders/:id/pay +} + +@server (prefix: /admin/v1) +service social-admin-api { + @handler AdminCreateUser + post /users (AdminCreateUserRequest) + @handler AdminUsers + get /users + @handler AdminUserDetail + get /users/:id + @handler AdminUpdateProfile + put /users/:id/profile (AdminProfileRequest) + @handler AdminUpdateVerification + put /users/:id/verification (VerificationRequest) + @handler AdminUpdateMembership + put /users/:id/membership (MembershipGrantRequest) + @handler AdminResetPassword + post /users/:id/password-reset (PasswordResetByAdminRequest) + @handler AdminForceLogout + post /users/:id/force-logout + @handler AdminUserSanctions + get /users/:id/sanctions + @handler AdminCreateSanction + post /users/:id/sanctions (SanctionRequest) + @handler AdminRevokeSanction + post /sanctions/:id/revoke + @handler AdminOrders + get /orders + @handler AdminUpdateOrder + put /orders/:id (AdminOrderUpdateRequest) + @handler AdminDeleteOrder + delete /orders/:id + @handler AdminMarkOrderPaid + post /orders/:id/pay + @handler AdminCloseOrder + post /orders/:id/close + @handler AdminRefundOrder + post /orders/:id/refund + @handler AdminMembershipPlans + get /membership/plans + @handler AdminCreateMembershipPlan + post /membership/plans (MembershipPlanRequest) + @handler AdminUpdateMembershipPlan + put /membership/plans/:id (MembershipPlanRequest) + @handler AdminDeleteMembershipPlan + delete /membership/plans/:id + @handler AdminMessages + get /messages + @handler AdminModerateMessage + post /messages/:id/moderate (MessageModerationRequest) + @handler AdminFeedback + get /client-feedback + @handler AdminHandleFeedback + put /client-feedback/:id (FeedbackHandleRequest) + @handler AdminAccountClosures + get /account-closures + @handler AdminCancelAccountClosure + put /account-closures/:id (AccountClosureHandleRequest) + @handler AdminAppVersions + get /app-versions + @handler AdminCreateAppVersion + post /app-versions (AppVersionRequest) + @handler AdminUpdateAppVersion + put /app-versions/:id (AppVersionRequest) + @handler AdminDeleteAppVersion + delete /app-versions/:id + @handler AdminAccounts + get /admin-users + @handler AdminCreateAccount + post /admin-users (AdminAccountRequest) + @handler AdminUpdateAccount + put /admin-users/:id (AdminAccountRequest) + @handler AdminRoles + get /admin-roles + @handler AdminCreateRole + post /admin-roles (AdminRoleRequest) + @handler AdminUpdateRole + put /admin-roles/:id (AdminRoleRequest) + @handler AdminDeleteRole + delete /admin-roles/:id + @handler AdminPermissions + get /admin-permissions +} diff --git a/im/.deploy/application-20260831-120612/backend/go.mod b/im/.deploy/application-20260831-120612/backend/go.mod new file mode 100644 index 0000000..1fc8a80 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/go.mod @@ -0,0 +1,73 @@ +module github.com/example/xingyu + +go 1.27.0 + +require ( + github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.6.0 + github.com/go-sql-driver/mysql v1.10.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/gorilla/websocket v1.5.3 + github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible + github.com/qiniu/go-sdk/v7 v7.27.0 + github.com/tencentyun/cos-go-sdk-v5 v0.7.75 + github.com/zeromicro/go-zero v1.10.3 + golang.org/x/crypto v0.48.0 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/BurntSushi/toml v1.3.2 // indirect + github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clbanning/mxj v1.8.4 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gofrs/flock v0.8.1 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/google/go-querystring v1.0.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grafana/pyroscope-go v1.3.0 // indirect + github.com/grafana/pyroscope-go/godeltaprof v0.1.10 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.4.3 // indirect + github.com/mozillazg/go-httpheader v0.2.1 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/openzipkin/zipkin-go v0.4.3 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/titanous/json5 v1.0.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/zipkin v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/net v0.50.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 + golang.org/x/time v0.14.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + modernc.org/fileutil v1.0.0 // indirect +) diff --git a/im/.deploy/application-20260831-120612/backend/go.sum b/im/.deploy/application-20260831-120612/backend/go.sum new file mode 100644 index 0000000..87ba84e --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/go.sum @@ -0,0 +1,187 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 h1:7dONQ3WNZ1zy960TmkxJPuwoolZwL7xKtpcM04MBnt4= +github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82/go.mod h1:nLnM0KdK1CmygvjpDUO6m1TjSsiQtL61juhNsvV/JVI= +github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.6.0 h1:uWzn3io54f9L9mvwsQQSv1KpkkFA06hBxI++RvIyvpI= +github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.6.0/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I= +github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grafana/pyroscope-go v1.3.0 h1:t3Jehad8vvqN4oRAB0LdmfQ5ZSUXQw3asoft+K4GAT8= +github.com/grafana/pyroscope-go v1.3.0/go.mod h1:XA7I3usNx+UdjOZfQnl1WV8y924vsJo9KIVrKB+9jx4= +github.com/grafana/pyroscope-go/godeltaprof v0.1.10 h1:dvhndEbyavTb59vFCd6PsrAG5qi69/qZZtegh/TJKSY= +github.com/grafana/pyroscope-go/godeltaprof v0.1.10/go.mod h1:XnWRGg2XO5uxZdiz1rfeJH6w1eZ+YICCBVXNWOfH86g= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= +github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible h1:lX3m9hvP5tSnJ8bFg/TdT2BYHj1nSBulealy5VN9mPU= +github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible/go.mod h1:l7VUhRbTKCzdOacdT4oWCwATKyvZqUOlOqr0Ous3k4s= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +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= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs= +github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mozillazg/go-httpheader v0.2.1 h1:geV7TrjbL8KXSyvghnFm+NyTux/hxwueTSrwhe88TQQ= +github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg= +github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/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/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/qiniu/go-sdk/v7 v7.27.0 h1:n+2U0S5fhbmG/lN/agO8KcYJaQDqROUVShtnp56Mkw8= +github.com/qiniu/go-sdk/v7 v7.27.0/go.mod h1:pTwVR1B+8SXcPLhDzBUasiKFTD9F7jRglRDR553BW3k= +github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGGF0= +github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY= +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/rs/dnscache v0.0.0-20230804202142-fc85eb664529/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +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/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0= +github.com/tencentyun/cos-go-sdk-v5 v0.7.75 h1:eRCGP5chujSYGFnsCPxlhQcN9wtlSO/4eXrxKtLVAIw= +github.com/tencentyun/cos-go-sdk-v5 v0.7.75/go.mod h1:STbTNaNKq03u+gscPEGOahKzLcGSYOj6Dzc5zNay7Pg= +github.com/tencentyun/qcloud-cos-sts-sdk v0.0.0-20250515025012-e0eec8a5d123/go.mod h1:b18KQa4IxHbxeseW1GcZox53d7J0z39VNONTxvvlkXw= +github.com/titanous/json5 v1.0.0 h1:hJf8Su1d9NuI/ffpxgxQfxh/UiBFZX7bMPid0rIL/7s= +github.com/titanous/json5 v1.0.0/go.mod h1:7JH1M8/LHKc6cyP5o5g3CSaRj+mBrIimTxzpvmckH8c= +github.com/zeromicro/go-zero v1.10.3 h1:fm4+jUuUF77IWtFeAyf2xVoBRcgEpF1NZJUqTvZ3dw0= +github.com/zeromicro/go-zero v1.10.3/go.mod h1:Gnac2bT/JGb9Ja79wchssVeYtJxuWWzL98DuLH11kds= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8= +go.opentelemetry.io/otel/exporters/zipkin v1.40.0 h1:zu+I4j+FdO6xIxBVPeuncQVbjxUM4LiMgv6GwGe9REE= +go.opentelemetry.io/otel/exporters/zipkin v1.40.0/go.mod h1:zS6cC4nFBYXbu18e7aLfMzubBjOiN7ZcROu477qtMf8= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= +gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= +gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= +gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +modernc.org/fileutil v1.0.0 h1:Z1AFLZwl6BO8A5NldQg/xTSjGLetp+1Ubvl4alfGx8w= +modernc.org/fileutil v1.0.0/go.mod h1:JHsWpkrk/CnVV1H/eGlFf85BEpfkrp56ro8nojIq9Q8= diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/admin.go b/im/.deploy/application-20260831-120612/backend/internal/app/admin.go new file mode 100644 index 0000000..99f687a --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/admin.go @@ -0,0 +1,818 @@ +package app + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +func (a *App) adminLogin(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "请输入账号和密码") + return + } + if !a.rateLimit(w, r, "admin_login_ip", clientIP(r), 10, 10*time.Minute) || !a.rateLimit(w, r, "admin_login_user", strings.ToLower(strings.TrimSpace(req.Username)), 10, 15*time.Minute) { + return + } + var id int64 + var hash, realName string + var status int + err := a.db.QueryRowContext(r.Context(), `SELECT id,password_hash,real_name,status FROM admin_users WHERE username=?`, req.Username).Scan(&id, &hash, &realName, &status) + if err != nil || !checkPassword(hash, req.Password) { + fail(w, 401, 10001, "账号或密码错误") + return + } + if status != 1 { + fail(w, 403, 10006, "管理员账号已停用") + return + } + payload, err := a.newAdminSession(r.Context(), w, r, id, realName) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建管理会话失败") + return + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE admin_users SET last_login_at=NOW(3) WHERE id=?`, id) + reply(w, payload) +} + +func (a *App) adminChangePassword(w http.ResponseWriter, r *http.Request) { + who := current(r) + if !a.rateLimit(w, r, "admin_password_change", strconv.FormatInt(who.ID, 10), 5, 30*time.Minute) { + return + } + var req struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` + } + if decode(r, &req) != nil || req.CurrentPassword == "" || req.NewPassword == "" { + fail(w, http.StatusBadRequest, 20001, "请输入当前密码和新密码") + return + } + + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "修改密码失败") + return + } + defer func() { _ = tx.Rollback() }() + + var passwordHash string + if err = tx.QueryRowContext(r.Context(), `SELECT password_hash FROM admin_users WHERE id=? AND status=1 FOR UPDATE`, who.ID).Scan(&passwordHash); err != nil || !checkPassword(passwordHash, req.CurrentPassword) { + fail(w, http.StatusBadRequest, 20001, "当前密码错误") + return + } + newHash, err := hashPassword(req.NewPassword) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "密码加密失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_users SET password_hash=?,token_version=token_version+1,password_changed_at=NOW(3) WHERE id=?`, newHash, who.ID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "修改密码失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_sessions SET revoked_at=NOW(3) WHERE admin_user_id=? AND revoked_at IS NULL`, who.ID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "撤销旧会话失败") + return + } + if err = tx.Commit(); err != nil { + fail(w, http.StatusInternalServerError, 50001, "修改密码失败") + return + } + a.clearAdminRefreshCookie(w) + a.audit(r, "change_password", "admin_user", who.ID, map[string]any{"allSessionsRevoked": true}) + reply(w, map[string]bool{"success": true, "reauthenticate": true}) +} + +func (a *App) adminInfo(w http.ResponseWriter, r *http.Request) { + who := current(r) + var username, realName, avatar string + if err := a.db.QueryRowContext(r.Context(), `SELECT username,real_name,avatar_url FROM admin_users WHERE id=?`, who.ID).Scan(&username, &realName, &avatar); err != nil { + fail(w, http.StatusUnauthorized, 10001, "管理员不存在") + return + } + roles, permissions, err := a.adminAccess(r.Context(), who.ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取管理员角色失败") + return + } + homePath := "/profile" + permissionHomes := []struct { + Code string + Path string + }{ + {"dashboard:view", "/analytics"}, + {"users:view", "/users"}, + {"content:view", "/posts"}, + {"messages:view", "/messages"}, + {"reports:handle", "/reports"}, + {"risk:view", "/risk"}, + {"membership:manage", "/membership"}, + {"orders:view", "/orders"}, + {"system:manage", "/configs"}, + } + permissionSet := map[string]bool{} + for _, permission := range permissions { + permissionSet[permission] = true + } + for _, candidate := range permissionHomes { + if permissionSet[candidate.Code] { + homePath = candidate.Path + break + } + } + reply(w, map[string]any{"userId": who.ID, "username": username, "realName": realName, "avatar": avatar, "roles": roles, "homePath": homePath}) +} + +func (a *App) dashboard(w http.ResponseWriter, r *http.Request) { + count := func(query string) int64 { + var value int64 + _ = a.db.QueryRowContext(r.Context(), query).Scan(&value) + return value + } + amount := func(query string) int64 { + var value sql.NullInt64 + _ = a.db.QueryRowContext(r.Context(), query).Scan(&value) + if value.Valid { + return value.Int64 + } + return 0 + } + trend := []map[string]any{} + for i := 6; i >= 0; i-- { + day := time.Now().AddDate(0, 0, -i) + var users, messages int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE is_test=0 AND DATE(created_at)=?`, day.Format("2006-01-02")).Scan(&users) + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM im_messages WHERE DATE(created_at)=?`, day.Format("2006-01-02")).Scan(&messages) + trend = append(trend, map[string]any{"date": day.Format("01-02"), "users": users, "messages": messages}) + } + reply(w, map[string]any{"metrics": map[string]any{"testUsers": count(`SELECT COUNT(*) FROM users WHERE is_test=1 AND deleted_at IS NULL`), "users": count(`SELECT COUNT(*) FROM users WHERE is_test=0 AND deleted_at IS NULL`), "newUsersToday": count(`SELECT COUNT(*) FROM users WHERE is_test=0 AND DATE(created_at)=CURDATE()`), "activeUsers": count(`SELECT COUNT(*) FROM user_profiles p JOIN users u ON u.id=p.user_id WHERE u.is_test=0 AND u.deleted_at IS NULL AND p.last_active_at>DATE_SUB(NOW(),INTERVAL 1 DAY)`), "posts": count(`SELECT COUNT(*) FROM posts WHERE status=1`), "messages": count(`SELECT COUNT(*) FROM im_messages`), "pendingReports": count(`SELECT COUNT(*) FROM reports WHERE status='PENDING'`), "paidOrders": count(`SELECT COUNT(*) FROM orders WHERE status='PAID' AND deleted_at IS NULL`), "revenueCent": amount(`SELECT SUM(amount_cent) FROM orders WHERE status='PAID' AND deleted_at IS NULL`)}, "trend": trend, "funnel": []map[string]any{{"name": "注册用户", "value": count(`SELECT COUNT(*) FROM users WHERE is_test=0`)}, {"name": "完善资料", "value": count(`SELECT COUNT(*) FROM user_profiles p JOIN users u ON u.id=p.user_id WHERE u.is_test=0 AND p.profile_score>=80`)}, {"name": "产生互动", "value": count(`SELECT COUNT(DISTINCT user_id) FROM user_likes`)}, {"name": "发起会话", "value": count(`SELECT COUNT(DISTINCT user_id) FROM im_conversation_members`)}, {"name": "付费会员", "value": count(`SELECT COUNT(DISTINCT user_id) FROM orders WHERE status='PAID' AND deleted_at IS NULL`)}}}) +} + +func (a *App) adminUsers(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + keyword := strings.TrimSpace(r.URL.Query().Get("keyword")) + status, _ := strconv.Atoi(r.URL.Query().Get("status")) + verificationStatus := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("verificationStatus"))) + where := ` WHERE u.deleted_at IS NULL` + args := []any{} + sourceWhere, sourceArgs, sourceErr := testUserFilter(r.URL.Query().Get("userType"), r.URL.Query().Get("testBatch")) + if sourceErr != nil { + fail(w, http.StatusBadRequest, 20001, sourceErr.Error()) + return + } + where += sourceWhere + args = append(args, sourceArgs...) + if keyword != "" { + where += ` AND (p.nickname LIKE ? OR u.public_id LIKE ? OR u.phone_hash=?)` + like := "%" + keyword + "%" + args = append(args, like, like, phoneHash(keyword)) + } + if status > 0 { + where += ` AND u.status=?` + args = append(args, status) + } + if verificationStatus != "" { + valid := verificationStatus == "UNVERIFIED" || verificationStatus == "PENDING" || verificationStatus == "VERIFIED" || verificationStatus == "REJECTED" + if !valid { + fail(w, http.StatusBadRequest, 20001, "认证状态筛选无效") + return + } + if verificationStatus == "UNVERIFIED" { + where += ` AND NOT EXISTS(SELECT 1 FROM user_verifications vx WHERE vx.user_id=u.id AND vx.verification_type='real_name' AND vx.status<>'UNVERIFIED')` + } else { + where += ` AND EXISTS(SELECT 1 FROM user_verifications vx WHERE vx.user_id=u.id AND vx.verification_type='real_name' AND vx.status=?)` + args = append(args, verificationStatus) + } + } + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users u JOIN user_profiles p ON p.user_id=u.id`+where, args...).Scan(&total) + query := `SELECT u.id,u.public_id,u.phone_cipher,u.status,u.risk_level,u.created_at,u.is_test,u.test_batch,p.nickname,p.avatar_url,p.gender,p.city_name,p.is_vip,p.vip_level,p.last_active_at,COALESCE(v.status,'UNVERIFIED'),(SELECT MAX(s.expires_at) FROM subscriptions s WHERE s.user_id=u.id AND s.status=1 AND s.expires_at>NOW(3)) FROM users u JOIN user_profiles p ON p.user_id=u.id LEFT JOIN user_verifications v ON v.user_id=u.id AND v.verification_type='real_name'` + where + ` ORDER BY u.created_at DESC,u.id DESC LIMIT ? OFFSET ?` + args = append(args, size, offset) + rows, err := a.db.QueryContext(r.Context(), query, args...) + if err != nil { + fail(w, 500, 50001, err.Error()) + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var publicID, nickname, avatar, city, verificationStatus string + var phoneCipher []byte + var status, risk, gender, vip, vipLevel int + var created time.Time + var active, vipExpiresAt sql.NullTime + var isTest bool + var testBatch string + if err = rows.Scan(&id, &publicID, &phoneCipher, &status, &risk, &created, &isTest, &testBatch, &nickname, &avatar, &gender, &city, &vip, &vipLevel, &active, &verificationStatus, &vipExpiresAt); err != nil { + fail(w, 500, 50001, "读取用户列表失败") + return + } + phone, _ := a.decryptPhone(phoneCipher) + items = append(items, map[string]any{"id": id, "publicId": publicID, "isTest": isTest, "testBatch": testBatch, "phone": maskPhone(phone), "nickname": nickname, "avatar": avatar, "gender": gender, "city": city, "vip": vip > 0, "vipLevel": vipLevel, "vipExpiresAt": nullableTime(vipExpiresAt), "verificationStatus": verificationStatus, "status": status, "riskLevel": risk, "lastActiveAt": nullableTime(active), "createdAt": created}) + } + if rows.Err() != nil { + fail(w, 500, 50001, "读取用户列表失败") + return + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} + +func (a *App) adminUserDetail(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "invalid id") + return + } + a.loadAdminUserDetail(w, r, id) +} + +func (a *App) adminUserStatus(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "invalid id") + return + } + status := 1 + action := "unban" + switch { + case strings.HasSuffix(r.URL.Path, "/freeze"): + status = 2 + action = "freeze" + case strings.HasSuffix(r.URL.Path, "/ban"): + status = 3 + action = "ban" + case strings.HasSuffix(r.URL.Path, "/unfreeze"): + action = "unfreeze" + } + var exists int + if a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE id=? AND deleted_at IS NULL`, id).Scan(&exists) != nil || exists == 0 { + fail(w, 404, 30001, "用户不存在") + return + } + if status != 1 { + typ := "FREEZE" + if status == 3 { + typ = "BAN" + } + if _, err = a.createSanction(r.Context(), current(r).ID, id, typ, "管理员执行账号状态操作", nil); err != nil { + fail(w, 500, 50001, "操作失败") + return + } + } else { + typ := "FREEZE" + if action == "unban" { + typ = "BAN" + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE user_sanctions SET status='REVOKED',revoked_by=?,revoked_at=NOW(3) WHERE user_id=? AND sanction_type=? AND status='ACTIVE'`, current(r).ID, id, typ) + var bans, freezes int + _ = a.db.QueryRowContext(r.Context(), `SELECT SUM(sanction_type='BAN'),SUM(sanction_type='FREEZE') FROM user_sanctions WHERE user_id=? AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>NOW(3))`, id).Scan(&bans, &freezes) + if bans > 0 { + status = 3 + } else if freezes > 0 { + status = 2 + } + _, err = a.db.ExecContext(r.Context(), `UPDATE users SET status=? WHERE id=?`, status, id) + if err != nil { + fail(w, 500, 50001, "操作失败") + return + } + } + a.audit(r, action, "user", id, map[string]any{"status": status}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminPosts(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL`).Scan(&total) + rows, err := a.db.QueryContext(r.Context(), `SELECT po.id,po.content,po.location_text,po.status,po.moderation_status,po.like_count,po.comment_count,po.created_at,p.user_id,p.nickname,p.avatar_url FROM posts po JOIN user_profiles p ON p.user_id=po.user_id WHERE po.deleted_at IS NULL ORDER BY po.created_at DESC LIMIT ? OFFSET ?`, size, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, userID int64 + var content, location, nickname, avatar string + var status, moderation, likes, comments int + var created time.Time + _ = rows.Scan(&id, &content, &location, &status, &moderation, &likes, &comments, &created, &userID, &nickname, &avatar) + items = append(items, map[string]any{"id": id, "content": content, "location": location, "status": status, "moderationStatus": moderation, "likeCount": likes, "commentCount": comments, "createdAt": created, "user": map[string]any{"id": userID, "nickname": nickname, "avatar": avatar}}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} + +func (a *App) adminPostDetail(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态 ID 无效") + return + } + var userID int64 + var publicID, nickname, avatar, cityName, content, cityCode, location string + var gender, vip, visibility, status, moderation, likes, comments int + var created, updated time.Time + err = a.db.QueryRowContext(r.Context(), `SELECT po.user_id,u.public_id,p.nickname,p.avatar_url,p.gender,p.city_name,p.is_vip,po.content,po.visibility,po.city_code,po.location_text,po.status,po.moderation_status,po.like_count,po.comment_count,po.created_at,po.updated_at FROM posts po JOIN users u ON u.id=po.user_id JOIN user_profiles p ON p.user_id=po.user_id WHERE po.id=? AND po.deleted_at IS NULL`, id).Scan(&userID, &publicID, &nickname, &avatar, &gender, &cityName, &vip, &content, &visibility, &cityCode, &location, &status, &moderation, &likes, &comments, &created, &updated) + if err == sql.ErrNoRows { + fail(w, 404, 20004, "动态不存在或已下架") + return + } + if err != nil { + fail(w, 500, 50001, "查询动态详情失败") + return + } + + media := []map[string]any{} + mediaRows, mediaErr := a.db.QueryContext(r.Context(), `SELECT id,media_url,media_type,sort_order FROM post_media WHERE post_id=? ORDER BY sort_order,id`, id) + if mediaErr == nil { + defer mediaRows.Close() + for mediaRows.Next() { + var mediaID int64 + var mediaURL, mediaType string + var sortOrder int + if mediaRows.Scan(&mediaID, &mediaURL, &mediaType, &sortOrder) == nil { + media = append(media, map[string]any{"id": mediaID, "url": mediaURL, "type": mediaType, "sortOrder": sortOrder}) + } + } + } + + commentItems := []map[string]any{} + commentRows, commentErr := a.db.QueryContext(r.Context(), `SELECT c.id,c.user_id,p.nickname,p.avatar_url,c.parent_comment_id,c.reply_user_id,c.content,c.status,c.moderation_status,c.created_at FROM post_comments c JOIN user_profiles p ON p.user_id=c.user_id WHERE c.post_id=? AND c.deleted_at IS NULL ORDER BY c.created_at DESC LIMIT 100`, id) + if commentErr == nil { + defer commentRows.Close() + for commentRows.Next() { + var commentID, commentUserID int64 + var commentNickname, commentAvatar, commentContent string + var parentID, replyUserID sql.NullInt64 + var commentStatus, commentModeration int + var commentCreated time.Time + if commentRows.Scan(&commentID, &commentUserID, &commentNickname, &commentAvatar, &parentID, &replyUserID, &commentContent, &commentStatus, &commentModeration, &commentCreated) == nil { + commentItems = append(commentItems, map[string]any{"id": commentID, "userId": commentUserID, "nickname": commentNickname, "avatar": commentAvatar, "parentCommentId": nullableID(parentID), "replyUserId": nullableID(replyUserID), "content": commentContent, "status": commentStatus, "moderationStatus": commentModeration, "createdAt": commentCreated}) + } + } + } + + likeItems := []map[string]any{} + likeRows, likeErr := a.db.QueryContext(r.Context(), `SELECT l.user_id,p.nickname,p.avatar_url,l.created_at FROM post_likes l JOIN user_profiles p ON p.user_id=l.user_id WHERE l.post_id=? ORDER BY l.created_at DESC LIMIT 100`, id) + if likeErr == nil { + defer likeRows.Close() + for likeRows.Next() { + var likeUserID int64 + var likeNickname, likeAvatar string + var likeCreated time.Time + if likeRows.Scan(&likeUserID, &likeNickname, &likeAvatar, &likeCreated) == nil { + likeItems = append(likeItems, map[string]any{"userId": likeUserID, "nickname": likeNickname, "avatar": likeAvatar, "createdAt": likeCreated}) + } + } + } + + var reportCount int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM reports WHERE target_type='post' AND target_id=?`, id).Scan(&reportCount) + reply(w, map[string]any{ + "id": id, "content": content, "visibility": visibility, "cityCode": cityCode, "location": location, + "status": status, "moderationStatus": moderation, "likeCount": likes, "commentCount": comments, + "reportCount": reportCount, "createdAt": created, "updatedAt": updated, "media": media, + "comments": commentItems, "likes": likeItems, + "user": map[string]any{"id": userID, "publicId": publicID, "nickname": nickname, "avatar": avatar, "gender": gender, "city": cityName, "vip": vip == 1}, + }) +} + +func nullableID(value sql.NullInt64) any { + if value.Valid { + return value.Int64 + } + return nil +} + +func (a *App) adminDeletePost(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE posts SET status=0,deleted_at=NOW(3) WHERE id=? AND status=1`, id) + if err != nil { + fail(w, 500, 50001, "删除失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 404, 30001, "动态不存在或已下架") + return + } + a.audit(r, "delete", "post", id, nil) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminReports(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + status := r.URL.Query().Get("status") + where := "" + args := []any{} + if status != "" { + where = ` WHERE r.status=?` + args = append(args, status) + } + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM reports r`+where, args...).Scan(&total) + args = append(args, size, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT r.id,r.reporter_user_id,p.nickname,r.target_type,r.target_id,r.reason_code,r.description,COALESCE(CAST(r.evidence_json AS CHAR CHARACTER SET utf8mb4),'[]'),r.status,r.action_type,r.handle_remark,r.handled_by,COALESCE(handler.real_name,''),r.handled_at,r.created_at FROM reports r JOIN user_profiles p ON p.user_id=r.reporter_user_id LEFT JOIN admin_users handler ON handler.id=r.handled_by`+where+` ORDER BY r.created_at DESC LIMIT ? OFFSET ?`, args...) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, reporter, target int64 + var nick, targetType, reason, description, evidenceJSON, status, actionType, handleRemark, handlerName string + var handler, handled any + var created time.Time + _ = rows.Scan(&id, &reporter, &nick, &targetType, &target, &reason, &description, &evidenceJSON, &status, &actionType, &handleRemark, &handler, &handlerName, &handled, &created) + evidence := []string{} + _ = json.Unmarshal([]byte(evidenceJSON), &evidence) + items = append(items, map[string]any{"id": id, "reporterId": reporter, "reporterName": nick, "targetType": targetType, "targetId": target, "reason": reason, "description": description, "evidence": evidence, "status": status, "actionType": actionType, "handleRemark": handleRemark, "handledBy": handler, "handlerName": handlerName, "handledAt": handled, "createdAt": created}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} +func (a *App) adminHandleReport(w http.ResponseWriter, r *http.Request) { + id, pathErr := pathID(r) + if pathErr != nil { + fail(w, 400, 20001, "举报编号无效") + return + } + var req struct { + Result string `json:"result"` + Remark string `json:"remark"` + SanctionType string `json:"sanctionType"` + DurationDays int `json:"durationDays"` + } + if decode(r, &req) != nil || (req.Result != "punished" && req.Result != "dismissed") { + fail(w, 400, 20001, "处理结果无效") + return + } + status := "REJECTED" + actionType := "NONE" + if req.Result == "punished" { + status = "PUNISHED" + } + var targetType string + var targetID, reporterID int64 + if a.db.QueryRowContext(r.Context(), `SELECT target_type,target_id,reporter_user_id FROM reports WHERE id=? AND status='PENDING'`, id).Scan(&targetType, &targetID, &reporterID) != nil { + fail(w, 404, 30001, "待处理举报不存在") + return + } + if status == "PUNISHED" { + if len([]rune(strings.TrimSpace(req.Remark))) < 2 { + fail(w, 400, 20001, "请填写明确的处理原因") + return + } + if targetType == "user" { + typ := strings.ToUpper(strings.TrimSpace(req.SanctionType)) + if typ == "" { + typ = "WARNING" + } + actionType = typ + if !map[string]bool{"WARNING": true, "FREEZE": true, "BAN": true, "MUTE": true, "CONTENT_LIMIT": true}[typ] { + fail(w, 400, 20001, "处罚类型无效") + return + } + if typ != "WARNING" && typ != "BAN" && (req.DurationDays < 1 || req.DurationDays > 3650) { + fail(w, 400, 20001, "临时处罚期限应为 1-3650 天") + return + } + var expiry *time.Time + if typ != "WARNING" && typ != "BAN" { + value := time.Now().AddDate(0, 0, req.DurationDays) + expiry = &value + } + if _, err := a.createSanction(r.Context(), current(r).ID, targetID, typ, "举报处理:"+strings.TrimSpace(req.Remark), expiry); err != nil { + fail(w, 500, 50001, "处罚执行失败") + return + } + } else if targetType == "post" { + actionType = "CONTENT_REMOVE" + result, actionErr := a.db.ExecContext(r.Context(), `UPDATE posts SET status=0,deleted_at=COALESCE(deleted_at,NOW(3)) WHERE id=? AND status=1`, targetID) + var affected int64 + if actionErr == nil { + affected, _ = result.RowsAffected() + } + if actionErr != nil || affected == 0 { + fail(w, 409, 20001, "动态已下架或不存在") + return + } + } else if targetType == "comment" { + actionType = "CONTENT_REMOVE" + tx, beginErr := a.db.BeginTx(r.Context(), nil) + if beginErr != nil { + fail(w, 500, 50001, "评论处置失败") + return + } + var postID int64 + if beginErr = tx.QueryRowContext(r.Context(), `SELECT post_id FROM post_comments WHERE id=? AND status=1 FOR UPDATE`, targetID).Scan(&postID); beginErr == nil { + _, beginErr = tx.ExecContext(r.Context(), `UPDATE post_comments SET status=0,deleted_at=COALESCE(deleted_at,NOW(3)) WHERE id=?`, targetID) + } + if beginErr == nil { + _, beginErr = tx.ExecContext(r.Context(), `UPDATE posts SET comment_count=GREATEST(comment_count-1,0) WHERE id=?`, postID) + } + if beginErr != nil || tx.Commit() != nil { + _ = tx.Rollback() + fail(w, 409, 20001, "评论已删除或不存在") + return + } + } else if targetType == "message" { + actionType = "CONTENT_REMOVE" + var conversationID, seq int64 + if a.db.QueryRowContext(r.Context(), `SELECT conversation_id,seq FROM im_messages WHERE id=?`, targetID).Scan(&conversationID, &seq) != nil { + fail(w, 404, 30001, "消息不存在") + return + } + if _, actionErr := a.db.ExecContext(r.Context(), `UPDATE im_messages SET moderation_status=0,admin_removed_at=NOW(3),admin_removed_by=?,admin_remove_reason=? WHERE id=?`, current(r).ID, strings.TrimSpace(req.Remark), targetID); actionErr != nil { + fail(w, 500, 50001, "消息处置失败") + return + } + rows, _ := a.db.QueryContext(r.Context(), `SELECT user_id FROM im_conversation_members WHERE conversation_id=? AND status=1`, conversationID) + members := []int64{} + if rows != nil { + for rows.Next() { + var memberID int64 + _ = rows.Scan(&memberID) + members = append(members, memberID) + } + _ = rows.Close() + } + a.hub.broadcast(members, map[string]any{"command": "MESSAGE_RECALLED", "data": map[string]any{"id": targetID, "conversationId": conversationID, "seq": seq}}) + } else { + fail(w, 400, 20001, "举报目标类型无效") + return + } + } + result, err := a.db.ExecContext(r.Context(), `UPDATE reports SET status=?,action_type=?,handle_remark=?,handled_by=?,handled_at=NOW(3) WHERE id=? AND status='PENDING'`, status, actionType, strings.TrimSpace(req.Remark), current(r).ID, id) + var affected int64 + if err == nil { + affected, _ = result.RowsAffected() + } + if err != nil || affected == 0 { + fail(w, 500, 50001, "处理失败") + return + } + a.audit(r, "handle", "report", id, req) + content := "举报已处理,感谢你帮助维护社区环境。" + if status == "REJECTED" { + content = "本次举报经核查暂未发现违规,感谢你的反馈。" + } + a.notifyUser(r.Context(), reporterID, "system", "举报处理结果", content, "report", id) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminRiskUsers(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_risk_profiles`).Scan(&total) + rows, err := a.db.QueryContext(r.Context(), `SELECT rp.user_id,p.nickname,p.avatar_url,rp.risk_score,rp.risk_level,rp.message_score,rp.device_score,rp.report_score,rp.behavior_score,rp.updated_at FROM user_risk_profiles rp JOIN user_profiles p ON p.user_id=rp.user_id ORDER BY rp.risk_score DESC LIMIT ? OFFSET ?`, size, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var nick, avatar string + var score, level, message, device, report, behavior int + var updated time.Time + _ = rows.Scan(&id, &nick, &avatar, &score, &level, &message, &device, &report, &behavior, &updated) + items = append(items, map[string]any{"userId": id, "nickname": nick, "avatar": avatar, "riskScore": score, "riskLevel": level, "messageScore": message, "deviceScore": device, "reportScore": report, "behaviorScore": behavior, "updatedAt": updated}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} +func (a *App) adminRiskEvents(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM risk_events`).Scan(&total) + rows, err := a.db.QueryContext(r.Context(), `SELECT e.id,e.user_id,p.nickname,e.event_type,e.score_delta,e.device_id,e.ip,e.metadata,e.created_at FROM risk_events e JOIN user_profiles p ON p.user_id=e.user_id ORDER BY e.created_at DESC LIMIT ? OFFSET ?`, size, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, userID int64 + var nick, typ, device, ip string + var delta int + var metadata []byte + var created time.Time + _ = rows.Scan(&id, &userID, &nick, &typ, &delta, &device, &ip, &metadata, &created) + var meta any + _ = json.Unmarshal(metadata, &meta) + items = append(items, map[string]any{"id": id, "userId": userID, "nickname": nick, "eventType": typ, "scoreDelta": delta, "deviceId": device, "ip": ip, "metadata": meta, "createdAt": created}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} + +func (a *App) adminPlans(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,code,name,level,duration_days,daily_active_chat_limit,daily_like_limit,can_view_visitors,can_invisible_visit,recommendation_weight,price_cent,original_price_cent,status,sort_order FROM membership_plans WHERE deleted_at IS NULL ORDER BY sort_order,id`) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []planView{} + for rows.Next() { + var item planView + _ = rows.Scan(&item.ID, &item.Code, &item.Name, &item.Level, &item.DurationDays, &item.DailyActiveChatLimit, &item.DailyLikeLimit, &item.CanViewVisitors, &item.CanInvisibleVisit, &item.RecommendationWeight, &item.PriceCent, &item.OriginalPriceCent, &item.Status, &item.SortOrder) + items = append(items, item) + } + reply(w, map[string]any{"items": items, "total": len(items)}) +} +func (a *App) adminCreatePlan(w http.ResponseWriter, r *http.Request) { + var req planView + if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" || strings.TrimSpace(req.Name) == "" || req.Level < 1 || req.Level > 20 || req.DurationDays < 1 || req.DailyActiveChatLimit < 0 || req.DailyActiveChatLimit > 10000 || req.DailyLikeLimit < 0 || req.DailyLikeLimit > 100000 || req.RecommendationWeight < 0 || req.RecommendationWeight > 10000 || req.PriceCent < 0 || req.OriginalPriceCent < req.PriceCent { + fail(w, 400, 20001, "请完整填写套餐信息,原价不能低于售价") + return + } + req.Code = strings.ToUpper(strings.TrimSpace(req.Code)) + req.Name = strings.TrimSpace(req.Name) + result, err := a.db.ExecContext(r.Context(), `INSERT INTO membership_plans(code,name,level,duration_days,daily_active_chat_limit,daily_like_limit,can_view_visitors,can_invisible_visit,recommendation_weight,price_cent,original_price_cent,status,sort_order)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, req.Code, req.Name, req.Level, req.DurationDays, req.DailyActiveChatLimit, req.DailyLikeLimit, req.CanViewVisitors, req.CanInvisibleVisit, req.RecommendationWeight, req.PriceCent, req.OriginalPriceCent, req.Status, req.SortOrder) + if err != nil { + fail(w, http.StatusConflict, 20001, "套餐编码已存在或数据无效") + return + } + id, _ := result.LastInsertId() + a.audit(r, "create", "membership_plan", id, req) + reply(w, map[string]any{"id": id}) +} +func (a *App) adminUpdatePlan(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + var req planView + if err != nil || decode(r, &req) != nil || strings.TrimSpace(req.Name) == "" || req.Level < 1 || req.Level > 20 || req.DurationDays < 1 || req.DailyActiveChatLimit < 0 || req.DailyActiveChatLimit > 10000 || req.DailyLikeLimit < 0 || req.DailyLikeLimit > 100000 || req.RecommendationWeight < 0 || req.RecommendationWeight > 10000 || req.PriceCent < 0 || req.OriginalPriceCent < req.PriceCent { + fail(w, 400, 20001, "套餐信息不完整或价格无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE membership_plans SET name=?,level=?,duration_days=?,daily_active_chat_limit=?,daily_like_limit=?,can_view_visitors=?,can_invisible_visit=?,recommendation_weight=?,price_cent=?,original_price_cent=?,status=?,sort_order=? WHERE id=? AND deleted_at IS NULL`, strings.TrimSpace(req.Name), req.Level, req.DurationDays, req.DailyActiveChatLimit, req.DailyLikeLimit, req.CanViewVisitors, req.CanInvisibleVisit, req.RecommendationWeight, req.PriceCent, req.OriginalPriceCent, req.Status, req.SortOrder, id) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 404, 30001, "套餐不存在或已删除") + return + } + a.audit(r, "update", "membership_plan", id, req) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminOrders(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + status := strings.TrimSpace(r.URL.Query().Get("status")) + keyword := strings.TrimSpace(r.URL.Query().Get("keyword")) + where := ` WHERE o.deleted_at IS NULL` + args := []any{} + if status != "" { + where += ` AND o.status=?` + args = append(args, status) + } + if keyword != "" { + where += ` AND (o.order_no LIKE ? OR p.nickname LIKE ? OR u.public_id LIKE ?)` + like := "%" + keyword + "%" + args = append(args, like, like, like) + } + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM orders o JOIN users u ON u.id=o.user_id JOIN user_profiles p ON p.user_id=o.user_id`+where, args...).Scan(&total) + args = append(args, size, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT o.id,o.order_no,o.user_id,u.public_id,p.nickname,o.product_id,COALESCE(mp.name,''),o.amount_cent,o.currency,o.status,o.channel,o.paid_at,o.created_at FROM orders o JOIN users u ON u.id=o.user_id JOIN user_profiles p ON p.user_id=o.user_id LEFT JOIN membership_plans mp ON mp.id=o.product_id`+where+` ORDER BY o.created_at DESC LIMIT ? OFFSET ?`, args...) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, userID, productID int64 + var orderNo, publicID, nickname, product, currency, orderStatus, channel string + var amount int + var paid any + var created time.Time + _ = rows.Scan(&id, &orderNo, &userID, &publicID, &nickname, &productID, &product, &amount, ¤cy, &orderStatus, &channel, &paid, &created) + items = append(items, map[string]any{"id": id, "orderNo": orderNo, "userId": userID, "publicId": publicID, "nickname": nickname, "productId": productID, "productName": product, "amountCent": amount, "currency": currency, "status": orderStatus, "channel": channel, "paidAt": paid, "createdAt": created}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} +func (a *App) adminRefund(w http.ResponseWriter, r *http.Request) { + if a.configPlain(r.Context(), "payment.mode", "sandbox") == "live" { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "订单编号无效") + return + } + a.requestLiveRefund(w, r, id) + return + } + a.adminOrderTransition(w, r, "refund") +} + +func (a *App) adminConfigs(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT config_key,config_value,value_type,description,updated_at FROM system_configs WHERE config_key NOT LIKE 'sms.%' AND config_key NOT LIKE 'payment.%' AND config_key NOT LIKE 'storage.%' AND config_key NOT LIKE 'oauth.%' ORDER BY config_key`) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var key, value, typ, description string + var updated time.Time + _ = rows.Scan(&key, &value, &typ, &description, &updated) + if typ == "secret" && value != "" { + value = maskedSecret + } + items = append(items, map[string]any{"key": key, "value": value, "type": typ, "description": description, "updatedAt": updated}) + } + reply(w, map[string]any{"items": items}) +} +func (a *App) adminUpdateConfig(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + key, _ := url.PathUnescape(parts[len(parts)-1]) + var req struct { + Value string `json:"value"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "invalid value") + return + } + var valueType string + if err := a.db.QueryRowContext(r.Context(), `SELECT value_type FROM system_configs WHERE config_key=?`, key).Scan(&valueType); err != nil { + fail(w, 404, 30001, "配置项不存在") + return + } + if valueType == "secret" { + if req.Value == maskedSecret || req.Value == "" { + reply(w, map[string]bool{"success": true}) + return + } + encrypted, err := a.encryptSecret(req.Value) + if err != nil { + fail(w, 500, 50001, "加密敏感配置失败") + return + } + req.Value = encrypted + } + _, err := a.db.ExecContext(r.Context(), `UPDATE system_configs SET config_value=? WHERE config_key=?`, req.Value, key) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + a.audit(r, "update", "system_config", 0, map[string]any{"key": key, "secret": valueType == "secret"}) + reply(w, map[string]bool{"success": true}) +} +func (a *App) adminAuditLogs(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM admin_audit_logs`).Scan(&total) + rows, err := a.db.QueryContext(r.Context(), `SELECT l.id,l.admin_user_id,a.real_name,l.action,l.target_type,l.target_id,l.request_data,l.ip,l.created_at FROM admin_audit_logs l JOIN admin_users a ON a.id=l.admin_user_id ORDER BY l.created_at DESC LIMIT ? OFFSET ?`, size, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, adminID int64 + var name, action, targetType, ip string + var targetID any + var data []byte + var created time.Time + _ = rows.Scan(&id, &adminID, &name, &action, &targetType, &targetID, &data, &ip, &created) + var request any + _ = json.Unmarshal(data, &request) + items = append(items, map[string]any{"id": id, "adminId": adminID, "adminName": name, "action": action, "targetType": targetType, "targetId": targetID, "request": request, "ip": ip, "createdAt": created}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} + +func (a *App) audit(r *http.Request, action, targetType string, targetID int64, data any) { + payload, _ := json.Marshal(data) + var id any + if targetID > 0 { + id = targetID + } + _, _ = a.db.ExecContext(r.Context(), `INSERT INTO admin_audit_logs(admin_user_id,action,target_type,target_id,request_data,ip)VALUES(?,?,?,?,?,?)`, current(r).ID, action, targetType, id, payload, clientIP(r)) +} +func maskPhone(value string) string { + if len(value) >= 7 { + return value[:3] + "****" + value[len(value)-4:] + } + return value +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/admin_client_operations.go b/im/.deploy/application-20260831-120612/backend/internal/app/admin_client_operations.go new file mode 100644 index 0000000..d11ac38 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/admin_client_operations.go @@ -0,0 +1,247 @@ +package app + +import ( + "database/sql" + "encoding/json" + "net/http" + "strconv" + "strings" + "time" +) + +func decodeStringArray(value string) []string { + items := []string{} + _ = json.Unmarshal([]byte(value), &items) + return items +} + +func (a *App) adminFeedback(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + page, size, offset := pagination(r) + status := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("status"))) + where, args := "", []any{} + if status != "" { + where = " WHERE f.status=?" + args = append(args, status) + } + var total int64 + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_feedback f`+where, args...).Scan(&total); err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询反馈失败") + return + } + queryArgs := append(append([]any{}, args...), size, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.user_id,u.public_id,p.nickname,p.avatar_url,f.category,f.content,f.contact,COALESCE(f.evidence_json,'[]'),f.status,f.reply_content,f.handled_by,f.handled_at,f.created_at,f.updated_at FROM user_feedback f JOIN users u ON u.id=f.user_id JOIN user_profiles p ON p.user_id=f.user_id`+where+` ORDER BY f.created_at DESC LIMIT ? OFFSET ?`, queryArgs...) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询反馈失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, userID int64 + var publicID, nickname, avatar, category, content, contact, evidence, itemStatus, replyContent string + var handledBy sql.NullInt64 + var handledAt sql.NullTime + var createdAt, updatedAt time.Time + if err = rows.Scan(&id, &userID, &publicID, &nickname, &avatar, &category, &content, &contact, &evidence, &itemStatus, &replyContent, &handledBy, &handledAt, &createdAt, &updatedAt); err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取反馈失败") + return + } + items = append(items, map[string]any{"id": id, "userId": userID, "publicId": publicID, "nickname": nickname, "avatar": avatar, "category": category, "content": content, "contact": contact, "evidence": decodeStringArray(evidence), "status": itemStatus, "replyContent": replyContent, "handledBy": nullableInt64(handledBy), "handledAt": nullableTime(handledAt), "createdAt": createdAt, "updatedAt": updatedAt}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) + return + } + id, err := pathID(r) + var req struct { + Status string `json:"status"` + Reply string `json:"reply"` + } + if err != nil || decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "反馈参数无效") + return + } + req.Status = strings.ToUpper(strings.TrimSpace(req.Status)) + if req.Status != "PROCESSING" && req.Status != "RESOLVED" && req.Status != "CLOSED" { + fail(w, http.StatusBadRequest, 20001, "反馈状态无效") + return + } + if req.Status == "RESOLVED" && strings.TrimSpace(req.Reply) == "" { + fail(w, http.StatusBadRequest, 20001, "解决反馈时必须填写回复") + return + } + var userID int64 + if err = a.db.QueryRowContext(r.Context(), `SELECT user_id FROM user_feedback WHERE id=?`, id).Scan(&userID); err != nil { + fail(w, http.StatusNotFound, 30001, "反馈不存在") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE user_feedback SET status=?,reply_content=?,handled_by=?,handled_at=NOW(3) WHERE id=?`, req.Status, strings.TrimSpace(req.Reply), current(r).ID, id) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新反馈失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusNotFound, 30001, "反馈不存在") + return + } + if strings.TrimSpace(req.Reply) != "" { + a.notifyUser(r.Context(), userID, "system", "反馈处理结果", strings.TrimSpace(req.Reply), "feedback", id) + } + a.audit(r, "handle_feedback", "feedback", id, map[string]any{"status": req.Status}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminAccountClosures(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + page, size, offset := pagination(r) + status := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("status"))) + where, args := "", []any{} + if status != "" { + where = " WHERE c.status=?" + args = append(args, status) + } + var total int64 + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_account_closures c`+where, args...).Scan(&total); err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询注销申请失败") + return + } + queryArgs := append(append([]any{}, args...), size, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT c.user_id,u.public_id,p.nickname,p.avatar_url,c.reason,c.status,c.requested_at,c.execute_after,c.cancelled_at,c.completed_at FROM user_account_closures c JOIN users u ON u.id=c.user_id JOIN user_profiles p ON p.user_id=c.user_id`+where+` ORDER BY c.requested_at DESC LIMIT ? OFFSET ?`, queryArgs...) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询注销申请失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var userID int64 + var publicID, nickname, avatar, reason, itemStatus string + var requestedAt, executeAfter time.Time + var cancelledAt, completedAt sql.NullTime + if err = rows.Scan(&userID, &publicID, &nickname, &avatar, &reason, &itemStatus, &requestedAt, &executeAfter, &cancelledAt, &completedAt); err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取注销申请失败") + return + } + items = append(items, map[string]any{"userId": userID, "publicId": publicID, "nickname": nickname, "avatar": avatar, "reason": reason, "status": itemStatus, "requestedAt": requestedAt, "executeAfter": executeAfter, "cancelledAt": nullableTime(cancelledAt), "completedAt": nullableTime(completedAt)}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) + return + } + userID, err := pathID(r) + var req struct { + Action string `json:"action"` + Reason string `json:"reason"` + } + if err != nil || decode(r, &req) != nil || strings.ToLower(req.Action) != "cancel" || strings.TrimSpace(req.Reason) == "" { + fail(w, http.StatusBadRequest, 20001, "必须填写取消注销的原因") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE user_account_closures SET status='CANCELLED',cancelled_at=NOW(3) WHERE user_id=? AND status='PENDING'`, userID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "取消注销申请失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusBadRequest, 20001, "只有待执行的注销申请可以取消") + return + } + a.notifyUser(r.Context(), userID, "system", "注销申请已取消", strings.TrimSpace(req.Reason), "account_closure", nil) + a.audit(r, "cancel_account_closure", "user", userID, map[string]any{"reason": req.Reason}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminAppVersions(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,platform,version,build_number,force_update,download_url,release_notes,status,created_at FROM app_versions ORDER BY platform,build_number DESC`) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询客户端版本失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var platform, version, downloadURL, notes string + var build, force, status int + var createdAt time.Time + if rows.Scan(&id, &platform, &version, &build, &force, &downloadURL, ¬es, &status, &createdAt) == nil { + items = append(items, map[string]any{"id": id, "platform": platform, "version": version, "buildNumber": build, "forceUpdate": force == 1, "downloadUrl": downloadURL, "releaseNotes": notes, "status": status, "createdAt": createdAt}) + } + } + reply(w, map[string]any{"items": items, "total": len(items)}) + return + } + if r.Method == http.MethodDelete { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "版本编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `DELETE FROM app_versions WHERE id=?`, id) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "删除版本失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusNotFound, 30001, "版本不存在") + return + } + a.audit(r, "delete_app_version", "app_version", id, nil) + reply(w, map[string]bool{"success": true}) + return + } + var req struct { + ID int64 `json:"id"` + Platform string `json:"platform"` + Version string `json:"version"` + BuildNumber int `json:"buildNumber"` + ForceUpdate bool `json:"forceUpdate"` + DownloadURL string `json:"downloadUrl"` + ReleaseNotes string `json:"releaseNotes"` + Status int `json:"status"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "版本参数无效") + return + } + req.Platform = strings.ToLower(strings.TrimSpace(req.Platform)) + if req.Platform != "android" && req.Platform != "ios" && req.Platform != "h5" || strings.TrimSpace(req.Version) == "" || req.BuildNumber <= 0 || req.Status < 0 || req.Status > 1 { + fail(w, http.StatusBadRequest, 20001, "平台、版本号或构建号无效") + return + } + force := 0 + if req.ForceUpdate { + force = 1 + } + if r.Method == http.MethodPost { + result, err := a.db.ExecContext(r.Context(), `INSERT INTO app_versions(platform,version,build_number,force_update,download_url,release_notes,status) VALUES(?,?,?,?,?,?,?)`, req.Platform, strings.TrimSpace(req.Version), req.BuildNumber, force, strings.TrimSpace(req.DownloadURL), strings.TrimSpace(req.ReleaseNotes), req.Status) + if err != nil { + fail(w, http.StatusConflict, 20001, "该平台构建号已存在") + return + } + id, _ := result.LastInsertId() + a.audit(r, "create_app_version", "app_version", id, map[string]any{"platform": req.Platform, "version": req.Version, "buildNumber": req.BuildNumber}) + reply(w, map[string]any{"id": id}) + return + } + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "版本编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE app_versions SET platform=?,version=?,build_number=?,force_update=?,download_url=?,release_notes=?,status=? WHERE id=?`, req.Platform, strings.TrimSpace(req.Version), req.BuildNumber, force, strings.TrimSpace(req.DownloadURL), strings.TrimSpace(req.ReleaseNotes), req.Status, id) + if err != nil { + fail(w, http.StatusConflict, 20001, "该平台构建号已存在") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusNotFound, 30001, "版本不存在") + return + } + a.audit(r, "update_app_version", "app_version", id, map[string]any{"platform": req.Platform, "version": req.Version, "buildNumber": req.BuildNumber, "status": strconv.Itoa(req.Status)}) + reply(w, map[string]bool{"success": true}) +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/admin_commerce_messages.go b/im/.deploy/application-20260831-120612/backend/internal/app/admin_commerce_messages.go new file mode 100644 index 0000000..1321f21 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/admin_commerce_messages.go @@ -0,0 +1,284 @@ +package app + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +func (a *App) adminDeletePlan(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "套餐编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE membership_plans SET status=0,deleted_at=NOW(3) WHERE id=? AND deleted_at IS NULL`, id) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "删除套餐失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusNotFound, 30001, "套餐不存在或已删除") + return + } + var subscriptions, orders int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM subscriptions WHERE plan_id=?`, id).Scan(&subscriptions) + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM orders WHERE product_id=?`, id).Scan(&orders) + a.audit(r, "delete", "membership_plan", id, map[string]any{"mode": "soft", "subscriptions": subscriptions, "orders": orders}) + reply(w, map[string]any{"success": true, "archivedSubscriptions": subscriptions, "archivedOrders": orders}) +} + +type adminOrderUpdateRequest struct { + AmountCent *int `json:"amountCent"` + Channel *string `json:"channel"` + ProductID *int64 `json:"productId"` + Status *string `json:"status"` +} + +func validOrderStatus(status string) bool { + return status == "CREATED" || status == "PAID" || status == "REFUND_REQUESTED" || status == "REFUNDING" || status == "REFUNDED" || status == "CLOSED" +} + +func orderEntitlementActive(status string) bool { + return status == "PAID" || status == "REFUND_REQUESTED" || status == "REFUNDING" +} + +func (a *App) syncEditedOrderEntitlement(r *http.Request, tx *sql.Tx, orderID, userID, oldPlanID, newPlanID int64, oldStatus, newStatus string) error { + needsRevoke := orderEntitlementActive(oldStatus) && (!orderEntitlementActive(newStatus) || oldPlanID != newPlanID) + needsGrant := orderEntitlementActive(newStatus) && (!orderEntitlementActive(oldStatus) || oldPlanID != newPlanID) + if needsRevoke { + if _, err := tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1 AND source IN (?,?)`, userID, fmt.Sprintf("order:%d", orderID), fmt.Sprintf("admin_order:%d", orderID)); err != nil { + return err + } + } + if needsGrant { + var durationDays int + if err := tx.QueryRowContext(r.Context(), `SELECT duration_days FROM membership_plans WHERE id=?`, newPlanID).Scan(&durationDays); err != nil { + return err + } + if _, err := tx.ExecContext(r.Context(), `INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(?,?,?,1,NOW(3),DATE_ADD(NOW(3),INTERVAL ? DAY))`, userID, newPlanID, fmt.Sprintf("admin_order:%d", orderID), durationDays); err != nil { + return err + } + } + if needsRevoke || needsGrant { + return a.recomputeMembershipTx(r.Context(), tx, userID) + } + return nil +} + +func (a *App) adminUpdateOrder(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + var req adminOrderUpdateRequest + if err != nil || decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "订单信息格式无效") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存订单失败") + return + } + defer func() { _ = tx.Rollback() }() + var userID, oldPlanID int64 + var oldAmount int + var oldStatus, oldChannel, providerOrderNo string + if err = tx.QueryRowContext(r.Context(), `SELECT user_id,product_id,amount_cent,status,channel,provider_order_no FROM orders WHERE id=? AND deleted_at IS NULL FOR UPDATE`, id).Scan(&userID, &oldPlanID, &oldAmount, &oldStatus, &oldChannel, &providerOrderNo); err != nil { + fail(w, http.StatusNotFound, 30001, "订单不存在或已删除") + return + } + newPlanID, newAmount, newStatus, newChannel := oldPlanID, oldAmount, oldStatus, oldChannel + if req.ProductID != nil { + newPlanID = *req.ProductID + } + if req.AmountCent != nil { + newAmount = *req.AmountCent + } + if req.Status != nil { + newStatus = strings.ToUpper(strings.TrimSpace(*req.Status)) + } + if req.Channel != nil { + newChannel = strings.TrimSpace(*req.Channel) + } + if newPlanID <= 0 || newAmount < 0 || !validOrderStatus(newStatus) || len(newChannel) > 30 { + fail(w, http.StatusBadRequest, 20001, "订单套餐、金额、渠道或状态无效") + return + } + if a.configPlain(r.Context(), "payment.mode", "sandbox") == "live" { + financialFieldsChanged := newPlanID != oldPlanID || newAmount != oldAmount || newChannel != oldChannel + if financialFieldsChanged && (oldStatus != "CREATED" || providerOrderNo != "") { + fail(w, http.StatusBadRequest, 20001, "生产订单创建支付流水后禁止修改套餐、金额或渠道") + return + } + if newStatus != oldStatus && !(oldStatus == "CREATED" && newStatus == "CLOSED") { + fail(w, http.StatusBadRequest, 20001, "生产订单的支付与退款状态只能由已验签回调更新") + return + } + } + var planExists int + planQuery := `SELECT COUNT(*) FROM membership_plans WHERE id=?` + if newStatus == "PAID" || newPlanID != oldPlanID { + planQuery += ` AND deleted_at IS NULL` + } + if err = tx.QueryRowContext(r.Context(), planQuery, newPlanID).Scan(&planExists); err != nil || planExists == 0 { + fail(w, http.StatusBadRequest, 20001, "选择的会员套餐不存在") + return + } + if err = a.syncEditedOrderEntitlement(r, tx, id, userID, oldPlanID, newPlanID, oldStatus, newStatus); err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE orders SET product_id=?,amount_cent=?,status=?,channel=?,paid_at=IF(?='PAID',COALESCE(paid_at,NOW(3)),paid_at) WHERE id=?`, newPlanID, newAmount, newStatus, newChannel, newStatus, id) + } + if err != nil || tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "保存订单及会员权益失败") + return + } + a.audit(r, "update", "order", id, map[string]any{"previousStatus": oldStatus, "status": newStatus, "previousPlanId": oldPlanID, "planId": newPlanID, "amountCent": newAmount, "channel": newChannel}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminDeleteOrder(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "订单编号无效") + return + } + var userID int64 + var status string + if err = a.db.QueryRowContext(r.Context(), `SELECT user_id,status FROM orders WHERE id=? AND deleted_at IS NULL`, id).Scan(&userID, &status); err != nil { + fail(w, http.StatusNotFound, 30001, "订单不存在或已删除") + return + } + if status == "REFUNDING" { + fail(w, http.StatusBadRequest, 20001, "退款处理中的订单不能删除") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE orders SET deleted_at=NOW(3) WHERE id=? AND deleted_at IS NULL`, id) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "删除订单失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusNotFound, 30001, "订单不存在或已删除") + return + } + a.audit(r, "delete", "order", id, map[string]any{"mode": "soft", "status": status, "userId": userID, "membershipPreserved": status == "PAID"}) + reply(w, map[string]any{"success": true, "membershipPreserved": status == "PAID"}) +} + +func (a *App) adminMessages(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + keyword := strings.TrimSpace(r.URL.Query().Get("keyword")) + conversationID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("conversationId")), 10, 64) + messageType, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("type"))) + where := ` WHERE 1=1` + args := []any{} + if conversationID > 0 { + where += ` AND m.conversation_id=?` + args = append(args, conversationID) + } + if messageType > 0 { + where += ` AND m.message_type=?` + args = append(args, messageType) + } + if keyword != "" { + where += ` AND (CONVERT(m.client_msg_id USING utf8mb4) LIKE ? OR CAST(m.body AS CHAR CHARACTER SET utf8mb4) LIKE ? OR sp.nickname LIKE ? OR su.public_id LIKE ? OR EXISTS (SELECT 1 FROM im_conversation_members kcm JOIN users ku ON ku.id=kcm.user_id JOIN user_profiles kp ON kp.user_id=kcm.user_id WHERE kcm.conversation_id=m.conversation_id AND kcm.user_id<>m.sender_id AND (kp.nickname LIKE ? OR ku.public_id LIKE ?)))` + like := "%" + keyword + "%" + args = append(args, like, like, like, like, like, like) + } + base := ` FROM im_messages m JOIN users su ON su.id=m.sender_id JOIN user_profiles sp ON sp.user_id=m.sender_id` + var total int64 + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*)`+base+where, args...).Scan(&total); err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询消息记录失败") + return + } + queryArgs := append(append([]any{}, args...), size, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,m.conversation_id,m.seq,m.sender_id,su.public_id,sp.nickname,sp.avatar_url,m.client_msg_id,m.message_type,m.body,m.moderation_status,m.recalled_at,m.admin_removed_at,m.admin_removed_by,m.admin_remove_reason,m.created_at,COALESCE((SELECT GROUP_CONCAT(CONCAT(kp.nickname,' (',ku.public_id,')') ORDER BY kp.nickname SEPARATOR '、') FROM im_conversation_members kcm JOIN users ku ON ku.id=kcm.user_id JOIN user_profiles kp ON kp.user_id=kcm.user_id WHERE kcm.conversation_id=m.conversation_id AND kcm.user_id<>m.sender_id),'')`+base+where+` ORDER BY m.created_at DESC,m.id DESC LIMIT ? OFFSET ?`, queryArgs...) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询消息记录失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, convID, seq, senderID int64 + var publicID, nickname, avatar, clientMsgID, recipients, adminRemoveReason string + var typ, moderation int + var body []byte + var recalledAt, adminRemovedAt sql.NullTime + var adminRemovedBy sql.NullInt64 + var createdAt time.Time + if rows.Scan(&id, &convID, &seq, &senderID, &publicID, &nickname, &avatar, &clientMsgID, &typ, &body, &moderation, &recalledAt, &adminRemovedAt, &adminRemovedBy, &adminRemoveReason, &createdAt, &recipients) != nil { + continue + } + var content any + if json.Unmarshal(body, &content) != nil { + content = string(body) + } + items = append(items, map[string]any{"id": id, "conversationId": convID, "seq": seq, "senderId": senderID, "senderPublicId": publicID, "senderNickname": nickname, "senderAvatar": avatar, "recipients": recipients, "clientMsgId": clientMsgID, "type": typ, "content": content, "moderationStatus": moderation, "recalledAt": nullableTime(recalledAt), "adminRemovedAt": nullableTime(adminRemovedAt), "adminRemovedBy": nullableInt64(adminRemovedBy), "adminRemoveReason": adminRemoveReason, "createdAt": createdAt}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} + +func (a *App) adminModerateMessage(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "消息编号无效") + return + } + var req struct { + Action string `json:"action"` + Reason string `json:"reason"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "消息处置参数无效") + return + } + req.Action = strings.ToUpper(strings.TrimSpace(req.Action)) + req.Reason = strings.TrimSpace(req.Reason) + if req.Action != "REMOVE" && req.Action != "RESTORE" { + fail(w, http.StatusBadRequest, 20001, "消息处置动作无效") + return + } + if req.Action == "REMOVE" && (len([]rune(req.Reason)) < 2 || len([]rune(req.Reason)) > 500) { + fail(w, http.StatusBadRequest, 20001, "请填写 2-500 字下架原因") + return + } + var conversationID, seq int64 + if err = a.db.QueryRowContext(r.Context(), `SELECT conversation_id,seq FROM im_messages WHERE id=?`, id).Scan(&conversationID, &seq); err != nil { + fail(w, http.StatusNotFound, 30001, "消息不存在") + return + } + var result sql.Result + command := "MESSAGE_RECALLED" + if req.Action == "REMOVE" { + result, err = a.db.ExecContext(r.Context(), `UPDATE im_messages SET moderation_status=0,admin_removed_at=NOW(3),admin_removed_by=?,admin_remove_reason=? WHERE id=? AND admin_removed_at IS NULL`, current(r).ID, req.Reason, id) + } else { + command = "MESSAGE_RESTORED" + result, err = a.db.ExecContext(r.Context(), `UPDATE im_messages SET moderation_status=1,admin_removed_at=NULL,admin_removed_by=NULL,admin_remove_reason='' WHERE id=? AND admin_removed_at IS NOT NULL`, id) + } + var affected int64 + if err == nil { + affected, _ = result.RowsAffected() + } + if err != nil || affected == 0 { + fail(w, http.StatusConflict, 20001, "消息状态已发生变化,请刷新后重试") + return + } + rows, _ := a.db.QueryContext(r.Context(), `SELECT user_id FROM im_conversation_members WHERE conversation_id=? AND status=1`, conversationID) + members := []int64{} + if rows != nil { + for rows.Next() { + var memberID int64 + _ = rows.Scan(&memberID) + members = append(members, memberID) + } + _ = rows.Close() + } + a.hub.broadcast(members, map[string]any{"command": command, "data": map[string]any{"id": id, "conversationId": conversationID, "seq": seq}}) + a.audit(r, strings.ToLower(req.Action)+"_message", "message", id, map[string]any{"reason": req.Reason}) + reply(w, map[string]bool{"success": true}) +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/admin_create_user.go b/im/.deploy/application-20260831-120612/backend/internal/app/admin_create_user.go new file mode 100644 index 0000000..fb21c8e --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/admin_create_user.go @@ -0,0 +1,103 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-sql-driver/mysql" +) + +type adminCreateUserRequest struct { + Phone string `json:"phone"` + Password string `json:"password"` + Nickname string `json:"nickname"` + Gender int `json:"gender"` + City string `json:"city"` + Bio string `json:"bio"` +} + +// Administrator provisioning uses the same credentials as public registration, +// but never creates a login session or marks the phone/identity as verified. +func (a *App) adminCreateUser(w http.ResponseWriter, r *http.Request) { + var req adminCreateUserRequest + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "用户资料格式不正确") + return + } + req.Phone = strings.TrimSpace(req.Phone) + req.Nickname = strings.TrimSpace(req.Nickname) + req.City = strings.TrimSpace(req.City) + req.Bio = strings.TrimSpace(req.Bio) + if !validPhone(req.Phone) { + fail(w, http.StatusBadRequest, 20001, "请输入有效的中国大陆手机号") + return + } + if !validUserPassword(req.Password) { + fail(w, http.StatusBadRequest, 20001, "请输入初始密码") + return + } + if req.Nickname == "" || len([]rune(req.Nickname)) > 50 || req.Gender < 0 || req.Gender > 2 || len([]rune(req.City)) > 50 || len([]rune(req.Bio)) > 500 { + fail(w, http.StatusBadRequest, 20001, "昵称须为 1–50 字,城市最多 50 字,简介最多 500 字,性别须为有效选项") + return + } + passwordHash, err := hashPassword(req.Password) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建用户失败") + return + } + phoneCipher, err := a.encryptPhone(req.Phone) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "加密手机号失败") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建用户失败") + return + } + defer func() { _ = tx.Rollback() }() + publicID := fmt.Sprintf("XY%d%s", time.Now().UnixMilli(), randomToken()[:5]) + result, err := tx.ExecContext(r.Context(), `INSERT INTO users (public_id,country_code,phone_hash,phone_cipher,password_hash) VALUES (?,'+86',?,?,?)`, publicID, phoneHash(req.Phone), phoneCipher, passwordHash) + if err != nil { + var mysqlErr *mysql.MySQLError + if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 && strings.Contains(mysqlErr.Message, "uk_users_phone_hash") { + fail(w, http.StatusConflict, 20001, "该手机号已被使用,请勿重复创建") + } else { + fail(w, http.StatusInternalServerError, 50001, "创建用户失败,请稍后重试") + } + return + } + userID, err := result.LastInsertId() + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建用户失败") + return + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_profiles (user_id,nickname,gender,city_name,bio,profile_score) VALUES (?,?,?,?,?,30)`, userID, req.Nickname, req.Gender, req.City, req.Bio); err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建用户资料失败") + return + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_privacy_settings (user_id) VALUES (?)`, userID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "初始化隐私设置失败") + return + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_notification_settings (user_id) VALUES (?)`, userID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "初始化通知设置失败") + return + } + // Audit and account creation succeed together. Do not store the request body: + // it contains the initial password and the full phone number. + auditData, _ := json.Marshal(map[string]any{"publicId": publicID, "nickname": req.Nickname, "source": "admin"}) + if _, err = tx.ExecContext(r.Context(), `INSERT INTO admin_audit_logs(admin_user_id,action,target_type,target_id,request_data,ip) VALUES (?,'create_user','user',?,?,?)`, current(r).ID, userID, auditData, clientIP(r)); err != nil { + fail(w, http.StatusInternalServerError, 50001, "记录创建日志失败,用户未创建") + return + } + if err = tx.Commit(); err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建用户失败") + return + } + reply(w, map[string]any{"id": userID, "publicId": publicID}) +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/admin_create_user_test.go b/im/.deploy/application-20260831-120612/backend/internal/app/admin_create_user_test.go new file mode 100644 index 0000000..9883ade --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/admin_create_user_test.go @@ -0,0 +1,317 @@ +package app + +import ( + "bytes" + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-sql-driver/mysql" +) + +// This transactional fixture exercises the real HTTP handlers without needing +// production credentials. Unexpected SQL is an error, including session writes +// during administrator provisioning. +type provisionedUser struct { + publicID, passwordHash, nickname, city, bio string + phoneHash, phoneCipher []byte + gender int64 + privacy, notification bool + audit []byte + sessions int +} + +type provisioningDB struct { + t *testing.T + user, pending *provisionedUser + allowed, inTx bool + failAt string + failure error + begins, rollbacks int +} + +type provisioningConnector struct{ store *provisioningDB } + +func (c provisioningConnector) Connect(context.Context) (driver.Conn, error) { return c.store, nil } +func (provisioningConnector) Driver() driver.Driver { return oauthTestDriver{} } +func (*provisioningDB) Prepare(string) (driver.Stmt, error) { + return nil, fmt.Errorf("unexpected prepare") +} +func (*provisioningDB) Close() error { return nil } +func (s *provisioningDB) Begin() (driver.Tx, error) { + s.begins++ + s.inTx = true + s.pending = nil + if s.user != nil { + copy := *s.user + s.pending = © + } + return s, nil +} +func (s *provisioningDB) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) { + return s.Begin() +} +func (s *provisioningDB) Commit() error { + s.user, s.pending, s.inTx = s.pending, nil, false + return nil +} +func (s *provisioningDB) Rollback() error { + s.pending, s.inTx = nil, false + s.rollbacks++ + return nil +} + +type provisioningInsertResult struct{} + +func (provisioningInsertResult) LastInsertId() (int64, error) { return 42, nil } +func (provisioningInsertResult) RowsAffected() (int64, error) { return 1, nil } + +func (s *provisioningDB) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + if s.failAt != "" && strings.Contains(query, s.failAt) { + return nil, s.failure + } + if strings.Contains(query, "api_rate_limits") { + return driver.RowsAffected(1), nil + } + if !s.inTx { + s.t.Errorf("account mutation outside transaction: %s", query) + return nil, fmt.Errorf("no transaction") + } + switch { + case strings.HasPrefix(query, "INSERT INTO users "): + if s.pending != nil && bytes.Equal(s.pending.phoneHash, args[1].Value.([]byte)) { + return nil, &mysql.MySQLError{Number: 1062, Message: "Duplicate entry for key 'users.uk_users_phone_hash'"} + } + s.pending = &provisionedUser{publicID: args[0].Value.(string), phoneHash: args[1].Value.([]byte), phoneCipher: args[2].Value.([]byte), passwordHash: args[3].Value.(string)} + return provisioningInsertResult{}, nil + case strings.HasPrefix(query, "INSERT INTO user_profiles "): + s.pending.nickname, s.pending.gender = args[1].Value.(string), args[2].Value.(int64) + s.pending.city, s.pending.bio = args[3].Value.(string), args[4].Value.(string) + case strings.HasPrefix(query, "INSERT INTO user_privacy_settings "): + s.pending.privacy = true + case strings.HasPrefix(query, "INSERT INTO user_notification_settings "): + s.pending.notification = true + case strings.HasPrefix(query, "INSERT INTO admin_audit_logs"): + if args[0].Value != int64(7) || args[1].Value != int64(42) { + s.t.Error("audit must identify both the administrator and created user") + } + s.pending.audit = args[2].Value.([]byte) + case strings.HasPrefix(query, "INSERT INTO user_sessions "): + s.pending.sessions++ + case strings.HasPrefix(query, "UPDATE user_sessions "), strings.HasPrefix(query, "INSERT INTO user_devices"), strings.HasPrefix(query, "UPDATE user_profiles SET last_active_at"): + default: + s.t.Errorf("unexpected exec: %s", query) + return nil, fmt.Errorf("unexpected exec") + } + return driver.RowsAffected(1), nil +} + +func (s *provisioningDB) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { + switch { + case strings.Contains(query, "SELECT status,token_version FROM admin_users"): + return oauthRow(int64(1), int64(0)), nil + case strings.Contains(query, "SELECT token_version FROM"): + return oauthRow(int64(0)), nil + case strings.Contains(query, "FROM admin_user_roles"): + allowed := int64(0) + if s.allowed && args[1].Value == "users:create" { + allowed = 1 + } + return oauthRow(allowed), nil + case strings.Contains(query, "SELECT hits FROM api_rate_limits"): + return oauthRow(int64(1)), nil + case strings.HasPrefix(query, "SELECT u.id,u.password_hash,u.status,p.nickname"): + if s.user == nil || !bytes.Equal(s.user.phoneHash, args[0].Value.([]byte)) { + return &oauthTestRows{columns: []string{"id", "password_hash", "status", "nickname"}}, nil + } + return oauthRow(int64(42), s.user.passwordHash, int64(1), s.user.nickname), nil + case strings.HasPrefix(query, "SELECT COUNT(*) FROM users u JOIN user_profiles"): + return oauthRow(int64(1)), nil + case strings.HasPrefix(query, "SELECT u.id,u.public_id,u.phone_cipher"): + u := s.user + return oauthRow(int64(42), u.publicID, u.phoneCipher, int64(1), int64(0), time.Now(), false, "", u.nickname, "", u.gender, u.city, int64(0), int64(0), nil, "UNVERIFIED", nil), nil + default: + s.t.Errorf("unexpected query: %s", query) + return nil, fmt.Errorf("unexpected query") + } +} + +func provisioningApp(t *testing.T) (*App, *provisioningDB, http.HandlerFunc, string) { + t.Helper() + store := &provisioningDB{t: t, allowed: true} + db := sql.OpenDB(provisioningConnector{store}) + t.Cleanup(func() { _ = db.Close() }) + a := &App{db: db, config: Config{JWTSecret: "test-provisioning-secret", Environment: "development"}} + token, err := a.token(7, "admin", "tester", time.Hour) + if err != nil { + t.Fatal(err) + } + for _, route := range a.adminRoutes() { + if route.Method == http.MethodPost && route.Path == "/admin/v1/users" { + return a, store, route.Handler, token + } + } + t.Fatal("admin create user route is not registered") + return nil, nil, nil, "" +} + +func provisionRequest(handler http.HandlerFunc, token string, payload any) *httptest.ResponseRecorder { + body, _ := json.Marshal(payload) + r := httptest.NewRequest(http.MethodPost, "/admin/v1/users", bytes.NewReader(body)) + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + handler(w, r) + return w +} + +func validProvisionPayload() map[string]any { + return map[string]any{"phone": "13800138000", "password": "Password123!", "nickname": " 管理员创建用户 ", "gender": 1, "city": " 北京 ", "bio": " 简介 "} +} + +func TestAdminCreateUserAndPasswordLogin(t *testing.T) { + a, store, handler, token := provisioningApp(t) + w := provisionRequest(handler, token, validProvisionPayload()) + if w.Code != http.StatusOK || store.user == nil { + t.Fatalf("create failed: %d %s", w.Code, w.Body.String()) + } + u := store.user + if !u.privacy || !u.notification || len(u.audit) == 0 || u.sessions != 0 { + t.Fatal("account must have default settings and an audit, but no login session") + } + if u.nickname != "管理员创建用户" || u.city != "北京" || u.bio != "简介" { + t.Fatal("profile whitespace was not normalized") + } + if len(u.publicID) > 20 || !strings.HasPrefix(u.publicID, "XY") { + t.Fatal("invalid public ID") + } + phone, err := a.decryptPhone(u.phoneCipher) + if err != nil || phone != "13800138000" || bytes.Contains(u.phoneCipher, []byte(phone)) { + t.Fatal("phone was not encrypted correctly") + } + if !checkPassword(u.passwordHash, "Password123!") || u.passwordHash == "Password123!" { + t.Fatal("password must be bcrypt hashed") + } + for _, sensitive := range []string{"13800138000", "Password123!", "accessToken", "refreshToken"} { + if strings.Contains(w.Body.String(), sensitive) || bytes.Contains(u.audit, []byte(sensitive)) { + t.Fatalf("creation leaked %s", sensitive) + } + } + list := httptest.NewRecorder() + a.adminUsers(list, httptest.NewRequest(http.MethodGet, "/admin/v1/users?keyword="+u.publicID, nil)) + if list.Code != 200 || !strings.Contains(list.Body.String(), u.publicID) || !strings.Contains(list.Body.String(), `"phone":"138****8000"`) { + t.Fatalf("new account missing from list: %s", list.Body.String()) + } + wrong := provisionRequest(a.loginPassword, "", map[string]any{"phone": phone, "password": "wrong-password"}) + if wrong.Code != http.StatusUnauthorized || store.user.sessions != 0 { + t.Fatal("incorrect initial password was accepted") + } + login := provisionRequest(a.loginPassword, "", map[string]any{"phone": phone, "password": "Password123!", "deviceId": "app-test"}) + var result struct { + Data struct { + AccessToken string `json:"accessToken"` + } `json:"data"` + } + if login.Code != http.StatusOK || json.Unmarshal(login.Body.Bytes(), &result) != nil || store.user.sessions != 1 { + t.Fatalf("password login failed: %d %s", login.Code, login.Body.String()) + } + who, err := a.parseToken(result.Data.AccessToken) + if err != nil || who.ID != 42 || who.Role != "user" { + t.Fatal("login did not issue a valid user token") + } +} + +func TestAdminCreateUserPermissions(t *testing.T) { + a, store, handler, token := provisioningApp(t) + store.allowed = false + if w := provisionRequest(handler, token, validProvisionPayload()); w.Code != http.StatusForbidden { + t.Fatalf("missing create permission accepted: %d", w.Code) + } + if w := provisionRequest(handler, "", validProvisionPayload()); w.Code != http.StatusUnauthorized { + t.Fatal("anonymous creation accepted") + } + userToken, _ := a.token(42, "user", "user", time.Hour) + if w := provisionRequest(handler, userToken, validProvisionPayload()); w.Code != http.StatusUnauthorized { + t.Fatal("client token accepted") + } + if store.begins != 0 { + t.Fatal("unauthorized caller reached account creation") + } +} + +func TestAdminCreateUserValidation(t *testing.T) { + cases := []struct { + key string + value any + }{ + {"phone", "12345678901"}, {"phone", ""}, {"password", ""}, + {"nickname", " "}, {"nickname", strings.Repeat("名", 51)}, + {"gender", 3}, {"gender", -1}, {"gender", 1.5}, {"city", strings.Repeat("城", 51)}, {"bio", strings.Repeat("文", 501)}, + {"isTest", true}, {"status", 3}, {"vip", true}, + } + for i, tc := range cases { + t.Run(fmt.Sprintf("%s-%d", tc.key, i), func(t *testing.T) { + _, store, handler, token := provisioningApp(t) + payload := validProvisionPayload() + payload[tc.key] = tc.value + w := provisionRequest(handler, token, payload) + if w.Code != http.StatusBadRequest || store.begins != 0 { + t.Fatalf("invalid input accepted: %d %s", w.Code, w.Body.String()) + } + }) + } +} + +func TestAdminCreateUserDuplicatePhonePreservesAccount(t *testing.T) { + _, store, handler, token := provisioningApp(t) + if w := provisionRequest(handler, token, validProvisionPayload()); w.Code != http.StatusOK { + t.Fatal(w.Body.String()) + } + original := store.user + payload := validProvisionPayload() + payload["nickname"], payload["password"], payload["phone"] = "覆盖用户", "Different123", " 13800138000 " + w := provisionRequest(handler, token, payload) + if w.Code != http.StatusConflict || store.user != original || store.rollbacks != 1 { + t.Fatalf("duplicate did not preserve original account: %d %s", w.Code, w.Body.String()) + } +} + +func TestAdminCreatedUserCanLoginWithSimpleOrLongPassword(t *testing.T) { + for _, password := range []string{"1", "letters", "中文", strings.Repeat("长密码", 30)} { + a, store, handler, token := provisioningApp(t) + payload := validProvisionPayload() + payload["password"] = password + created := provisionRequest(handler, token, payload) + if created.Code != http.StatusOK { + t.Fatalf("password rejected: %d %s", created.Code, created.Body.String()) + } + login := provisionRequest(a.loginPassword, "", map[string]any{"phone": payload["phone"], "password": password}) + if login.Code != http.StatusOK || store.user.sessions != 1 { + t.Fatalf("new password cannot login: %d %s", login.Code, login.Body.String()) + } + } +} + +func TestAdminCreateUserRollsBackEveryFailedWrite(t *testing.T) { + for _, table := range []string{"users", "user_profiles", "user_privacy_settings", "user_notification_settings", "admin_audit_logs"} { + t.Run(table, func(t *testing.T) { + _, store, handler, token := provisioningApp(t) + store.failAt, store.failure = "INSERT INTO "+table, fmt.Errorf("database unavailable") + w := provisionRequest(handler, token, validProvisionPayload()) + if w.Code != http.StatusInternalServerError || store.user != nil || store.rollbacks != 1 { + t.Fatalf("failed %s write left partial account: %d %s", table, w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "手机号已被使用") { + t.Fatal("database error misreported as duplicate phone") + } + }) + } +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/admin_oauth.go b/im/.deploy/application-20260831-120612/backend/internal/app/admin_oauth.go new file mode 100644 index 0000000..c2e3324 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/admin_oauth.go @@ -0,0 +1,772 @@ +package app + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + adminOAuthStateTTL = 10 * time.Minute + adminOAuthCodeTTL = 5 * time.Minute + adminOAuthBodyMax = 1 << 20 +) + +var adminOAuthProviderNames = map[string]string{ + "wechat": "微信", + "qq": "QQ", + "github": "GitHub", + "google": "Google", +} + +var adminOAuthAllowedHosts = map[string]map[string]bool{ + "wechat": {"open.weixin.qq.com": true, "api.weixin.qq.com": true}, + "qq": {"graph.qq.com": true}, + "github": {"github.com": true, "api.github.com": true}, + "google": {"accounts.google.com": true, "oauth2.googleapis.com": true, "openidconnect.googleapis.com": true}, +} + +type adminOAuthProvider struct { + Code string + Name string + ClientID string + ClientSecret string + AuthorizationURL string + TokenURL string + OpenIDURL string + UserInfoURL string + Scope string + RedirectURI string +} + +type adminOAuthIdentity struct { + Subject string + Email string + DisplayName string + AvatarURL string +} + +type adminOAuthLoginCode struct { + Provider string + Subject string + Email string + DisplayName string + AvatarURL string + AdminUserID sql.NullInt64 +} + +func oauthHash(value string) []byte { + hash := sha256.Sum256([]byte(value)) + return hash[:] +} + +func pkceChallenge(verifier string) string { + hash := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(hash[:]) +} + +func (a *App) adminOAuthProvider(ctx context.Context, code string) (adminOAuthProvider, error) { + name, ok := adminOAuthProviderNames[code] + if !ok { + return adminOAuthProvider{}, errors.New("不支持的第三方登录渠道") + } + prefix := "oauth." + code + "." + provider := adminOAuthProvider{ + Code: code, + Name: name, + ClientID: strings.TrimSpace(a.configPlain(ctx, prefix+"client_id", "")), + ClientSecret: strings.TrimSpace(a.configPlain(ctx, prefix+"client_secret", "")), + AuthorizationURL: strings.TrimSpace(a.configPlain(ctx, prefix+"authorization_url", "")), + TokenURL: strings.TrimSpace(a.configPlain(ctx, prefix+"token_url", "")), + OpenIDURL: strings.TrimSpace(a.configPlain(ctx, prefix+"openid_url", "")), + UserInfoURL: strings.TrimSpace(a.configPlain(ctx, prefix+"userinfo_url", "")), + Scope: strings.TrimSpace(a.configPlain(ctx, prefix+"scope", "")), + RedirectURI: strings.TrimSpace(a.configPlain(ctx, prefix+"redirect_uri", "")), + } + if provider.ClientID == "" || provider.ClientSecret == "" || provider.AuthorizationURL == "" || provider.TokenURL == "" || provider.UserInfoURL == "" || provider.Scope == "" || provider.RedirectURI == "" { + return adminOAuthProvider{}, fmt.Errorf("%s登录配置不完整", name) + } + if code == "qq" && provider.OpenIDURL == "" { + return adminOAuthProvider{}, errors.New("QQ 登录 OpenID 地址未配置") + } + for label, raw := range map[string]string{ + "授权地址": provider.AuthorizationURL, + "令牌地址": provider.TokenURL, + "用户信息地址": provider.UserInfoURL, + } { + if err := validateAdminOAuthEndpoint(code, raw); err != nil { + return adminOAuthProvider{}, fmt.Errorf("%s%s无效:%w", name, label, err) + } + } + if provider.OpenIDURL != "" { + if err := validateAdminOAuthEndpoint(code, provider.OpenIDURL); err != nil { + return adminOAuthProvider{}, fmt.Errorf("%s OpenID 地址无效:%w", name, err) + } + } + if err := a.validateAdminOAuthRedirectURL(provider.RedirectURI); err != nil { + return adminOAuthProvider{}, fmt.Errorf("%s回调地址无效:%w", name, err) + } + return provider, nil +} + +func validateAdminOAuthEndpoint(provider, raw string) error { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil { + return errors.New("必须是合法的 HTTPS 地址") + } + if !adminOAuthAllowedHosts[provider][strings.ToLower(parsed.Hostname())] { + return errors.New("域名不在该渠道的官方白名单内") + } + if port := parsed.Port(); port != "" && port != "443" { + return errors.New("仅允许使用标准 HTTPS 端口") + } + return nil +} + +func (a *App) validateAdminOAuthRedirectURL(raw string) error { + parsed, err := url.Parse(raw) + if err != nil || parsed.Hostname() == "" || parsed.User != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") { + return errors.New("必须是合法的 HTTP(S) 地址") + } + if parsed.Scheme == "http" { + host := strings.ToLower(parsed.Hostname()) + if a.config.Environment == "production" || (host != "localhost" && host != "127.0.0.1" && host != "::1") { + return errors.New("仅本地开发允许 HTTP,生产环境必须使用 HTTPS") + } + } + return nil +} + +func (a *App) adminOAuthFrontendURL(ctx context.Context) (string, error) { + raw := strings.TrimSpace(a.configPlain(ctx, "oauth.admin.frontend_callback_url", "")) + if raw == "" { + return "", errors.New("管理端登录结果页未配置") + } + if err := a.validateAdminOAuthRedirectURL(raw); err != nil { + return "", fmt.Errorf("管理端登录结果页无效:%w", err) + } + return raw, nil +} + +func (a *App) enabledAdminOAuthProviders(ctx context.Context) ([]adminOAuthProvider, error) { + providers := make([]adminOAuthProvider, 0, len(adminOAuthProviderNames)) + for _, code := range []string{"wechat", "qq", "github", "google"} { + if !a.configBool(ctx, "oauth."+code+".enabled", false) { + continue + } + provider, err := a.adminOAuthProvider(ctx, code) + if err != nil { + return nil, err + } + providers = append(providers, provider) + } + return providers, nil +} + +func (a *App) oauthConfigurationReady(ctx context.Context) bool { + adminProviders, err := a.enabledAdminOAuthProviders(ctx) + if err != nil { + return false + } + userProviders, err := a.enabledUserOAuthProviders(ctx) + if err != nil { + return false + } + if len(adminProviders) > 0 { + if _, err := a.adminOAuthFrontendURL(ctx); err != nil { + return false + } + } + if len(userProviders) > 0 { + if _, err := a.userOAuthFrontendURL(ctx); err != nil { + return false + } + } + _, err = a.enabledAppOAuthProviders(ctx) + return err == nil +} + +func (a *App) validateAdminOAuthConfigValues(ctx context.Context, values map[string]string, clearSecrets map[string]bool) error { + value := func(key string) string { + if clearSecrets[key] { + return "" + } + if candidate, exists := values[key]; exists { + if candidate != "" || !strings.HasSuffix(key, "client_secret") { + return strings.TrimSpace(candidate) + } + } + return strings.TrimSpace(a.configPlain(ctx, key, "")) + } + for _, client := range []struct{ prefix, callback, name string }{ + {"oauth.", "oauth.admin.frontend_callback_url", "管理端"}, + {"oauth.user.", "oauth.user.frontend_callback_url", "H5"}, + } { + for _, code := range []string{"wechat", "qq", "github", "google"} { + if value(client.prefix+code+".enabled") == "true" { + if err := a.validateAdminOAuthRedirectURL(value(client.callback)); err != nil { + return fmt.Errorf("%s 登录结果页无效:%w", client.name, err) + } + break + } + } + } + for _, code := range []string{"wechat", "qq", "github", "google"} { + adminEnabled := strings.ToLower(value("oauth."+code+".enabled")) == "true" + userEnabled := strings.ToLower(value("oauth.user."+code+".enabled")) == "true" + appEnabled := value("oauth.app."+code+".enabled") == "true" + if appEnabled { + if code == "github" { + if value("oauth.app.frontend_callback_url") != appOAuthCallbackURL { + return errors.New("App 回调地址必须为 " + appOAuthCallbackURL) + } + } else if err := validateNativeOAuthConfig(code, value); err != nil { + return err + } + } + if !adminEnabled && !userEnabled && !(appEnabled && code == "github") { + continue + } + prefix := "oauth." + code + "." + required := []string{"client_id", "client_secret", "authorization_url", "token_url", "userinfo_url", "scope", "redirect_uri"} + if code == "qq" { + required = append(required, "openid_url") + } + for _, suffix := range required { + if value(prefix+suffix) == "" { + return fmt.Errorf("%s登录的%s不能为空", adminOAuthProviderNames[code], suffix) + } + } + for label, raw := range map[string]string{ + "授权地址": value(prefix + "authorization_url"), + "令牌地址": value(prefix + "token_url"), + "用户信息地址": value(prefix + "userinfo_url"), + } { + if err := validateAdminOAuthEndpoint(code, raw); err != nil { + return fmt.Errorf("%s%s无效:%w", adminOAuthProviderNames[code], label, err) + } + } + if code == "qq" { + if err := validateAdminOAuthEndpoint(code, value(prefix+"openid_url")); err != nil { + return fmt.Errorf("QQ OpenID 地址无效:%w", err) + } + } + if err := a.validateAdminOAuthRedirectURL(value(prefix + "redirect_uri")); err != nil { + return fmt.Errorf("%s回调地址无效:%w", adminOAuthProviderNames[code], err) + } + } + return nil +} + +func (a *App) adminOAuthProviders(w http.ResponseWriter, r *http.Request) { + items := make([]map[string]string, 0, len(adminOAuthProviderNames)) + for _, code := range []string{"wechat", "qq", "github", "google"} { + if !a.configBool(r.Context(), "oauth."+code+".enabled", false) { + continue + } + provider, err := a.adminOAuthProvider(r.Context(), code) + if err != nil { + // A broken channel must not hide other correctly configured channels. + continue + } + items = append(items, map[string]string{"code": provider.Code, "name": provider.Name}) + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) adminOAuthStart(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "请选择第三方登录渠道") + return + } + req.Provider = strings.ToLower(strings.TrimSpace(req.Provider)) + if !a.rateLimit(w, r, "admin_oauth_start", clientIP(r), 30, 10*time.Minute) { + return + } + if !a.configBool(r.Context(), "oauth."+req.Provider+".enabled", false) { + fail(w, http.StatusBadRequest, 20001, "该登录方式未启用") + return + } + provider, err := a.adminOAuthProvider(r.Context(), req.Provider) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "该登录方式配置不完整") + return + } + state := randomToken() + verifier := "" + if provider.Code == "github" || provider.Code == "google" { + verifier = randomToken() + randomToken() + } + _, err = a.db.ExecContext(r.Context(), `INSERT INTO admin_oauth_states(state_hash,provider,code_verifier,expires_at) VALUES(?,?,?,?)`, oauthHash(state), provider.Code, verifier, time.Now().Add(adminOAuthStateTTL)) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建第三方登录请求失败") + return + } + a.cleanupAdminOAuthRecords(r.Context()) + + authorizationURL, _ := url.Parse(provider.AuthorizationURL) + query := authorizationURL.Query() + if provider.Code == "wechat" { + query.Set("appid", provider.ClientID) + } else { + query.Set("client_id", provider.ClientID) + } + query.Set("redirect_uri", provider.RedirectURI) + query.Set("response_type", "code") + query.Set("scope", provider.Scope) + query.Set("state", state) + if verifier != "" { + query.Set("code_challenge", pkceChallenge(verifier)) + query.Set("code_challenge_method", "S256") + } + authorizationURL.RawQuery = query.Encode() + if provider.Code == "wechat" { + authorizationURL.Fragment = "wechat_redirect" + } + reply(w, map[string]string{"authorizationUrl": authorizationURL.String(), "provider": provider.Code}) +} + +func (a *App) adminOAuthCallback(w http.ResponseWriter, r *http.Request) { + frontendURL, frontendErr := a.adminOAuthFrontendURL(r.Context()) + if frontendErr != nil { + fail(w, http.StatusServiceUnavailable, 50001, "第三方登录回调未配置") + return + } + state := strings.TrimSpace(r.URL.Query().Get("state")) + if state == "" { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "登录状态无效或已过期") + return + } + var providerCode, verifier string + err := a.db.QueryRowContext(r.Context(), `SELECT provider,code_verifier FROM admin_oauth_states WHERE state_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(state)).Scan(&providerCode, &verifier) + if err != nil { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "登录状态无效或已过期") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE admin_oauth_states SET used_at=NOW(3) WHERE state_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(state)) + if err != nil { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "第三方登录处理失败") + return + } + affected, _ := result.RowsAffected() + if affected != 1 { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "登录状态已被使用") + return + } + if providerError := strings.TrimSpace(r.URL.Query().Get("error")); providerError != "" { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "第三方授权已取消或失败") + return + } + code := strings.TrimSpace(r.URL.Query().Get("code")) + if code == "" { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "第三方平台未返回授权码") + return + } + if !a.configBool(r.Context(), "oauth."+providerCode+".enabled", false) { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "该登录方式已停用") + return + } + provider, err := a.adminOAuthProvider(r.Context(), providerCode) + if err != nil { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "该登录方式配置不可用") + return + } + identity, err := a.fetchAdminOAuthIdentity(r.Context(), provider, code, verifier) + if err != nil { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "获取第三方账号信息失败") + return + } + var adminUserID sql.NullInt64 + _ = a.db.QueryRowContext(r.Context(), `SELECT admin_user_id FROM admin_oauth_identities WHERE provider=? AND subject=?`, provider.Code, identity.Subject).Scan(&adminUserID) + loginCode := randomToken() + _, err = a.db.ExecContext(r.Context(), `INSERT INTO admin_oauth_login_codes(code_hash,provider,subject,email,display_name,avatar_url,admin_user_id,expires_at) VALUES(?,?,?,?,?,?,?,?)`, oauthHash(loginCode), provider.Code, identity.Subject, identity.Email, identity.DisplayName, identity.AvatarURL, adminUserID, time.Now().Add(adminOAuthCodeTTL)) + if err != nil { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "创建登录凭证失败") + return + } + a.redirectAdminOAuthResult(w, r, frontendURL, loginCode, "") +} + +func (a *App) redirectAdminOAuthResult(w http.ResponseWriter, r *http.Request, frontendURL, code, message string) { + target, err := url.Parse(frontendURL) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "登录结果页地址无效") + return + } + query := target.Query() + if code != "" { + query.Set("oauthCode", code) + } else { + query.Set("oauthError", message) + } + target.RawQuery = query.Encode() + http.Redirect(w, r, target.String(), http.StatusFound) +} + +func (a *App) adminOAuthExchange(w http.ResponseWriter, r *http.Request) { + var req struct { + Code string `json:"code"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效") + return + } + if !a.rateLimit(w, r, "admin_oauth_exchange", clientIP(r), 20, 10*time.Minute) { + return + } + loginCode, err := a.readAdminOAuthLoginCode(r.Context(), strings.TrimSpace(req.Code), false) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效或已过期") + return + } + if !loginCode.AdminUserID.Valid { + reply(w, map[string]any{ + "requiresLink": true, + "provider": loginCode.Provider, + "providerName": adminOAuthProviderNames[loginCode.Provider], + "displayName": loginCode.DisplayName, + "email": loginCode.Email, + "avatarUrl": loginCode.AvatarURL, + }) + return + } + adminID, realName, err := a.consumeAdminOAuthCode(r.Context(), strings.TrimSpace(req.Code), loginCode.AdminUserID.Int64) + if err != nil { + fail(w, http.StatusUnauthorized, 10001, err.Error()) + return + } + payload, err := a.newAdminSession(r.Context(), w, r, adminID, realName) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建管理会话失败") + return + } + payload["requiresLink"] = false + reply(w, payload) +} + +func (a *App) adminOAuthLink(w http.ResponseWriter, r *http.Request) { + var req struct { + Code string `json:"code"` + Username string `json:"username"` + Password string `json:"password"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" || strings.TrimSpace(req.Username) == "" || req.Password == "" { + fail(w, http.StatusBadRequest, 20001, "请输入管理员账号和密码完成绑定") + return + } + username := strings.TrimSpace(req.Username) + if !a.rateLimit(w, r, "admin_oauth_link_ip", clientIP(r), 10, 15*time.Minute) || !a.rateLimit(w, r, "admin_oauth_link_user", strings.ToLower(username), 10, 15*time.Minute) { + return + } + loginCode, err := a.readAdminOAuthLoginCode(r.Context(), strings.TrimSpace(req.Code), true) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效或已过期") + return + } + var adminID int64 + var passwordHash, realName string + var status int + err = a.db.QueryRowContext(r.Context(), `SELECT id,password_hash,real_name,status FROM admin_users WHERE username=?`, username).Scan(&adminID, &passwordHash, &realName, &status) + if err != nil || !checkPassword(passwordHash, req.Password) { + // 这里返回 400,避免前端全局 401 拦截器丢弃尚可重试的一次性绑定码。 + fail(w, http.StatusBadRequest, 10001, "管理员账号或密码错误") + return + } + if status != 1 { + fail(w, http.StatusForbidden, 10006, "管理员账号已停用") + return + } + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(r.Context(), `UPDATE admin_oauth_login_codes SET used_at=NOW(3),admin_user_id=? WHERE code_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, adminID, oauthHash(strings.TrimSpace(req.Code))) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + affected, _ := result.RowsAffected() + if affected != 1 { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证已被使用") + return + } + _, err = tx.ExecContext(r.Context(), `INSERT INTO admin_oauth_identities(provider,subject,admin_user_id,email,display_name,avatar_url,last_login_at) VALUES(?,?,?,?,?,?,NOW(3))`, loginCode.Provider, loginCode.Subject, adminID, loginCode.Email, loginCode.DisplayName, loginCode.AvatarURL) + if err != nil { + fail(w, http.StatusConflict, 20001, "该第三方账号或管理员账号已绑定此渠道") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_users SET last_login_at=NOW(3) WHERE id=?`, adminID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + if err = tx.Commit(); err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + auditPayload, _ := json.Marshal(map[string]any{"provider": loginCode.Provider, "externalSubjectHash": fmt.Sprintf("%x", sha256.Sum256([]byte(loginCode.Subject)))}) + _, _ = a.db.ExecContext(r.Context(), `INSERT INTO admin_audit_logs(admin_user_id,action,target_type,target_id,request_data,ip) VALUES(?,?,?,?,?,?)`, adminID, "bind_oauth_identity", "admin_user", adminID, auditPayload, clientIP(r)) + payload, err := a.newAdminSession(r.Context(), w, r, adminID, realName) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建管理会话失败") + return + } + payload["requiresLink"] = false + reply(w, payload) +} + +func (a *App) readAdminOAuthLoginCode(ctx context.Context, code string, requireUnlinked bool) (adminOAuthLoginCode, error) { + var result adminOAuthLoginCode + query := `SELECT provider,subject,email,display_name,avatar_url,admin_user_id FROM admin_oauth_login_codes WHERE code_hash=? AND used_at IS NULL AND expires_at>NOW(3)` + if requireUnlinked { + query += ` AND admin_user_id IS NULL` + } + err := a.db.QueryRowContext(ctx, query, oauthHash(code)).Scan(&result.Provider, &result.Subject, &result.Email, &result.DisplayName, &result.AvatarURL, &result.AdminUserID) + return result, err +} + +func (a *App) consumeAdminOAuthCode(ctx context.Context, code string, adminID int64) (int64, string, error) { + var realName string + var status int + if err := a.db.QueryRowContext(ctx, `SELECT real_name,status FROM admin_users WHERE id=?`, adminID).Scan(&realName, &status); err != nil || status != 1 { + return 0, "", errors.New("管理员账号不存在或已停用") + } + result, err := a.db.ExecContext(ctx, `UPDATE admin_oauth_login_codes SET used_at=NOW(3) WHERE code_hash=? AND admin_user_id=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(code), adminID) + if err != nil { + return 0, "", errors.New("第三方登录处理失败") + } + affected, _ := result.RowsAffected() + if affected != 1 { + return 0, "", errors.New("第三方登录凭证无效或已使用") + } + _, _ = a.db.ExecContext(ctx, `UPDATE admin_users SET last_login_at=NOW(3) WHERE id=?`, adminID) + _, _ = a.db.ExecContext(ctx, `UPDATE admin_oauth_identities SET last_login_at=NOW(3) WHERE admin_user_id=?`, adminID) + return adminID, realName, nil +} + +func (a *App) cleanupAdminOAuthRecords(ctx context.Context) { + _, _ = a.db.ExecContext(ctx, `DELETE FROM admin_oauth_states WHERE expires_at= 300 { + return "", "", errors.New("第三方令牌服务请求失败") + } + var payload struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if json.Unmarshal(body, &payload) != nil || payload.AccessToken == "" { + parsed, parseErr := url.ParseQuery(string(body)) + if parseErr != nil { + return "", "", errors.New("第三方令牌响应无效") + } + payload.AccessToken = parsed.Get("access_token") + payload.Error = parsed.Get("error") + } + if payload.Error != "" || payload.AccessToken == "" { + return "", "", errors.New("第三方平台拒绝了令牌请求") + } + return payload.AccessToken, "", nil +} + +func (a *App) oauthBearerJSON(ctx context.Context, endpoint, accessToken string, out any) error { + request, _ := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + request.Header.Set("Authorization", "Bearer "+accessToken) + request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", "XingYu-Admin-OAuth/1.0") + return a.oauthDoJSON(request, out) +} + +func (a *App) oauthGetJSON(ctx context.Context, endpoint string, values url.Values, out any) error { + parsed, err := url.Parse(endpoint) + if err != nil { + return err + } + query := parsed.Query() + for key, items := range values { + for _, item := range items { + query.Add(key, item) + } + } + parsed.RawQuery = query.Encode() + request, _ := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", "XingYu-Admin-OAuth/1.0") + return a.oauthDoJSON(request, out) +} + +func (a *App) oauthDoJSON(request *http.Request, out any) error { + response, err := a.oauthHTTPClient().Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("第三方平台返回 HTTP %d", response.StatusCode) + } + decoder := json.NewDecoder(io.LimitReader(response.Body, adminOAuthBodyMax)) + return decoder.Decode(out) +} + +func (a *App) fetchQQOpenID(ctx context.Context, endpoint, accessToken string) (string, error) { + parsed, _ := url.Parse(endpoint) + query := parsed.Query() + query.Set("access_token", accessToken) + query.Set("fmt", "json") + parsed.RawQuery = query.Encode() + request, _ := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + request.Header.Set("Accept", "application/json") + response, err := a.oauthHTTPClient().Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, adminOAuthBodyMax)) + if err != nil || response.StatusCode < 200 || response.StatusCode >= 300 { + return "", errors.New("QQ OpenID 请求失败") + } + text := strings.TrimSpace(string(body)) + if strings.HasPrefix(text, "callback") { + start, end := strings.Index(text, "("), strings.LastIndex(text, ")") + if start >= 0 && end > start { + text = text[start+1 : end] + } + } + var payload struct { + OpenID string `json:"openid"` + Error int `json:"error"` + } + if json.Unmarshal([]byte(text), &payload) != nil || payload.Error != 0 || payload.OpenID == "" { + return "", errors.New("QQ OpenID 响应无效") + } + return payload.OpenID, nil +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/admin_oauth_test.go b/im/.deploy/application-20260831-120612/backend/internal/app/admin_oauth_test.go new file mode 100644 index 0000000..2b43844 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/admin_oauth_test.go @@ -0,0 +1,74 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestAdminOAuthEndpointAllowlist(t *testing.T) { + valid := map[string]string{ + "wechat": "https://api.weixin.qq.com/sns/userinfo", + "qq": "https://graph.qq.com/user/get_user_info", + "github": "https://api.github.com/user", + "google": "https://openidconnect.googleapis.com/v1/userinfo", + } + for provider, endpoint := range valid { + if err := validateAdminOAuthEndpoint(provider, endpoint); err != nil { + t.Fatalf("expected %s endpoint to be accepted: %v", provider, err) + } + } + invalid := []struct { + provider string + endpoint string + }{ + {"github", "http://api.github.com/user"}, + {"github", "https://127.0.0.1/user"}, + {"google", "https://evil.example.com/token"}, + {"github", "https://api.github.com:8443/user"}, + {"qq", "javascript:alert(1)"}, + } + for _, item := range invalid { + if err := validateAdminOAuthEndpoint(item.provider, item.endpoint); err == nil { + t.Fatalf("expected endpoint to be rejected: %s", item.endpoint) + } + } +} + +func TestAdminOAuthRedirectURLPolicy(t *testing.T) { + production := &App{config: productionConfigForTest()} + if err := production.validateAdminOAuthRedirectURL("https://admin.example.com/auth/social-callback"); err != nil { + t.Fatalf("expected HTTPS callback to be accepted: %v", err) + } + if err := production.validateAdminOAuthRedirectURL("http://localhost:5560/auth/social-callback"); err == nil { + t.Fatal("expected production HTTP callback to be rejected") + } + development := &App{config: Config{Environment: "development"}} + if err := development.validateAdminOAuthRedirectURL("http://127.0.0.1:8888/admin/v1/auth/oauth/callback"); err != nil { + t.Fatalf("expected local development callback to be accepted: %v", err) + } + if err := development.validateAdminOAuthRedirectURL("http://admin.example.com/callback"); err == nil { + t.Fatal("expected non-local HTTP callback to be rejected") + } +} + +func TestUserOAuthResultKeepsHashRouteAndAddsQuery(t *testing.T) { + app := &App{} + request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/callback", nil) + recorder := httptest.NewRecorder() + app.redirectUserOAuthResult(recorder, request, "http://localhost:5174/#/pages/auth/oauth-callback", "one-time-code", "") + if recorder.Code != http.StatusFound { + t.Fatalf("expected redirect status, got %d", recorder.Code) + } + target, err := url.Parse(recorder.Header().Get("Location")) + if err != nil { + t.Fatalf("invalid redirect URL: %v", err) + } + if target.Query().Get("oauthCode") != "one-time-code" { + t.Fatalf("missing one-time code in redirect: %s", target.String()) + } + if target.Fragment != "/pages/auth/oauth-callback" { + t.Fatalf("hash route was lost: %s", target.String()) + } +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/admin_operations.go b/im/.deploy/application-20260831-120612/backend/internal/app/admin_operations.go new file mode 100644 index 0000000..bbc1e10 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/admin_operations.go @@ -0,0 +1,572 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +func (a *App) loadAdminUserDetail(w http.ResponseWriter, r *http.Request, id int64) { + var publicID, nickname, avatar, cover, cityCode, city, occupation, bio string + var phoneCipher []byte + var birthday sql.NullString + var created time.Time + var lastActive sql.NullTime + var status, risk, gender, height, profileScore, vip, vipLevel int + var followingCount, followerCount, postCount, likeCount int + var isTest bool + var testBatch string + err := a.db.QueryRowContext(r.Context(), `SELECT u.public_id,u.phone_cipher,u.status,u.risk_level,u.created_at,u.is_test,u.test_batch, + p.nickname,p.avatar_url,p.cover_url,p.gender,DATE_FORMAT(p.birthday,'%Y-%m-%d'),COALESCE(p.height_cm,0),p.city_code,p.city_name,p.occupation,p.bio,p.profile_score,p.is_vip,p.vip_level,p.last_active_at, + (SELECT COUNT(*) FROM user_follows WHERE user_id=u.id),(SELECT COUNT(*) FROM user_follows WHERE target_user_id=u.id),(SELECT COUNT(*) FROM posts WHERE user_id=u.id AND deleted_at IS NULL),(SELECT COUNT(*) FROM post_likes pl JOIN posts po ON po.id=pl.post_id WHERE po.user_id=u.id) + FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.id=? AND u.deleted_at IS NULL`, id).Scan( + &publicID, &phoneCipher, &status, &risk, &created, &isTest, &testBatch, &nickname, &avatar, &cover, &gender, &birthday, &height, &cityCode, &city, &occupation, &bio, &profileScore, &vip, &vipLevel, &lastActive, &followingCount, &followerCount, &postCount, &likeCount) + if err != nil { + fail(w, http.StatusNotFound, 30001, "用户不存在") + return + } + phone, decryptErr := a.decryptPhone(phoneCipher) + if decryptErr != nil { + fail(w, http.StatusInternalServerError, 50001, "用户手机号解密失败") + return + } + + verification := map[string]any{"status": "UNVERIFIED", "type": "real_name", "realName": "", "documentMask": "", "remark": "", "evidence": []string{}} + verifications := []map[string]any{} + verificationRows, _ := a.db.QueryContext(r.Context(), `SELECT verification_type,status,real_name,document_mask,remark,COALESCE(evidence_json,'[]'),submitted_at,reviewed_at FROM user_verifications WHERE user_id=? ORDER BY verification_type='real_name' DESC,verification_type`, id) + if verificationRows != nil { + defer verificationRows.Close() + for verificationRows.Next() { + var verificationType, verificationStatus, realName, documentMask, verificationRemark, evidenceJSON string + var submittedAt, reviewedAt sql.NullTime + if verificationRows.Scan(&verificationType, &verificationStatus, &realName, &documentMask, &verificationRemark, &evidenceJSON, &submittedAt, &reviewedAt) != nil { + continue + } + evidence := []string{} + _ = json.Unmarshal([]byte(evidenceJSON), &evidence) + item := map[string]any{"type": verificationType, "status": verificationStatus, "realName": realName, "documentMask": documentMask, "remark": verificationRemark, "evidence": evidence, "submittedAt": nullableTime(submittedAt), "reviewedAt": nullableTime(reviewedAt)} + verifications = append(verifications, item) + if len(verifications) == 1 { + verification = item + } + } + } + + membership := map[string]any{"active": vip == 1, "level": vipLevel, "name": "普通用户"} + if vip == 1 { + membership["name"] = "历史会员资料" + } + var subscriptionID, planID int64 + var planName string + var level int + var startedAt, expiresAt time.Time + if a.db.QueryRowContext(r.Context(), `SELECT s.id,s.plan_id,p.name,p.level,s.started_at,s.expires_at FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id WHERE s.user_id=? AND s.status=1 AND s.expires_at>NOW(3) ORDER BY p.level DESC,s.expires_at DESC LIMIT 1`, id).Scan(&subscriptionID, &planID, &planName, &level, &startedAt, &expiresAt) == nil { + membership = map[string]any{"active": true, "subscriptionId": subscriptionID, "planId": planID, "name": planName, "level": level, "startedAt": startedAt, "expiresAt": expiresAt} + } + + devices := []map[string]any{} + rows, _ := a.db.QueryContext(r.Context(), `SELECT device_id,platform,device_model,os_version,app_version,last_ip,last_active_at,status FROM user_devices WHERE user_id=? ORDER BY COALESCE(last_active_at,created_at) DESC LIMIT 20`, id) + if rows != nil { + defer rows.Close() + for rows.Next() { + var deviceID, platform, model, osVersion, appVersion, ip string + var active any + var deviceStatus int + _ = rows.Scan(&deviceID, &platform, &model, &osVersion, &appVersion, &ip, &active, &deviceStatus) + devices = append(devices, map[string]any{"deviceId": deviceID, "platform": platform, "model": model, "osVersion": osVersion, "appVersion": appVersion, "ip": ip, "lastActiveAt": active, "status": deviceStatus}) + } + } + var activeSessions, orderCount, paidCent int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_sessions WHERE user_id=? AND revoked_at IS NULL AND expires_at>NOW(3)`, id).Scan(&activeSessions) + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*),COALESCE(SUM(IF(status='PAID',amount_cent,0)),0) FROM orders WHERE user_id=? AND deleted_at IS NULL`, id).Scan(&orderCount, &paidCent) + + reply(w, map[string]any{ + "id": id, "publicId": publicID, "isTest": isTest, "testBatch": testBatch, "phone": phone, "status": status, "riskLevel": risk, "createdAt": created, + "profile": map[string]any{"id": id, "publicId": publicID, "nickname": nickname, "avatar": avatar, "cover": cover, "gender": gender, "birthday": nullableString(birthday), "height": height, "cityCode": cityCode, "city": city, "occupation": occupation, "bio": bio, "profileScore": profileScore, "vip": vip == 1, "vipLevel": vipLevel, "lastActiveAt": nullableTime(lastActive), "followingCount": followingCount, "followerCount": followerCount, "postCount": postCount, "likeCount": likeCount}, + "verification": verification, "verifications": verifications, "membership": membership, "sanctions": a.sanctionList(r.Context(), id), "devices": devices, + "security": map[string]any{"activeSessions": activeSessions}, "orderSummary": map[string]any{"count": orderCount, "paidCent": paidCent}, + }) +} + +func nullableTime(value sql.NullTime) any { + if value.Valid { + return value.Time + } + return nil +} + +func (a *App) adminUpdateUserProfile(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "用户编号无效") + return + } + var req struct { + Phone string `json:"phone"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Cover string `json:"cover"` + Gender int `json:"gender"` + Birthday string `json:"birthday"` + Height int `json:"height"` + CityCode string `json:"cityCode"` + City string `json:"city"` + Occupation string `json:"occupation"` + Bio string `json:"bio"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.Nickname) == "" || len([]rune(req.Nickname)) > 50 || req.Gender < 0 || req.Gender > 2 || req.Height < 0 || req.Height > 260 { + fail(w, 400, 20001, "用户资料格式不正确") + return + } + if req.Birthday != "" { + if _, err = time.Parse("2006-01-02", req.Birthday); err != nil { + fail(w, 400, 20001, "生日格式应为 YYYY-MM-DD") + return + } + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + defer func() { _ = tx.Rollback() }() + if strings.TrimSpace(req.Phone) != "" { + if !validPhone(req.Phone) { + fail(w, 400, 20001, "手机号格式不正确") + return + } + phoneCipher, encryptErr := a.encryptPhone(req.Phone) + if encryptErr != nil { + fail(w, 500, 50001, "加密手机号失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE users SET phone_hash=?,phone_cipher=? WHERE id=?`, phoneHash(req.Phone), phoneCipher, id); err != nil { + fail(w, http.StatusConflict, 20001, "手机号已被其他账号使用") + return + } + } + _, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET nickname=?,avatar_url=?,cover_url=?,gender=?,birthday=NULLIF(?,''),height_cm=NULLIF(?,0),city_code=?,city_name=?,occupation=?,bio=?,profile_score=GREATEST(profile_score,80) WHERE user_id=?`, strings.TrimSpace(req.Nickname), strings.TrimSpace(req.Avatar), strings.TrimSpace(req.Cover), req.Gender, req.Birthday, req.Height, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.City), strings.TrimSpace(req.Occupation), strings.TrimSpace(req.Bio), id) + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "保存失败") + return + } + a.audit(r, "update_profile", "user", id, map[string]any{"nickname": req.Nickname, "phoneChanged": req.Phone != "", "city": req.City}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminUpdateVerification(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "用户编号无效") + return + } + var req struct { + Type string `json:"type"` + Status string `json:"status"` + RealName string `json:"realName"` + DocumentMask string `json:"documentMask"` + Remark string `json:"remark"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "认证资料格式错误") + return + } + req.Status = strings.ToUpper(strings.TrimSpace(req.Status)) + if req.Type == "" { + req.Type = "real_name" + } + if req.Status != "UNVERIFIED" && req.Status != "PENDING" && req.Status != "VERIFIED" && req.Status != "REJECTED" { + fail(w, 400, 20001, "认证状态无效") + return + } + if req.Status == "REJECTED" && strings.TrimSpace(req.Remark) == "" { + fail(w, 400, 20001, "驳回认证时必须填写原因") + return + } + _, err = a.db.ExecContext(r.Context(), `INSERT INTO user_verifications(user_id,verification_type,status,real_name,document_mask,evidence_json,remark,reviewer_admin_id,submitted_at,reviewed_at) VALUES(?,?,?,?,?,'[]',?,?,IF(?='PENDING',NOW(3),NULL),IF(? IN ('VERIFIED','REJECTED'),NOW(3),NULL)) ON DUPLICATE KEY UPDATE verification_type=VALUES(verification_type),status=VALUES(status),real_name=VALUES(real_name),document_mask=VALUES(document_mask),remark=VALUES(remark),reviewer_admin_id=VALUES(reviewer_admin_id),submitted_at=IF(VALUES(status)='PENDING',COALESCE(submitted_at,NOW(3)),submitted_at),reviewed_at=IF(VALUES(status) IN ('VERIFIED','REJECTED'),NOW(3),NULL)`, id, req.Type, req.Status, strings.TrimSpace(req.RealName), strings.TrimSpace(req.DocumentMask), strings.TrimSpace(req.Remark), current(r).ID, req.Status, req.Status) + if err != nil { + fail(w, 500, 50001, "保存认证结果失败") + return + } + if req.Status == "VERIFIED" || req.Status == "REJECTED" { + title := "认证审核结果" + content := "认证已通过" + if req.Status == "REJECTED" { + content = "认证未通过:" + strings.TrimSpace(req.Remark) + } + a.notifyUser(r.Context(), id, "system", title, content, "verification", id) + } + a.audit(r, "verify", "user", id, map[string]any{"status": req.Status, "type": req.Type, "remark": req.Remark}) + reply(w, map[string]bool{"success": true}) +} + +func parseAdminExpiry(value string, fallbackDays int) (time.Time, error) { + if strings.TrimSpace(value) == "" { + return time.Now().AddDate(0, 0, fallbackDays), nil + } + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return parsed, nil + } + parsed, err := time.ParseInLocation("2006-01-02", value, time.Local) + if err != nil { + return time.Time{}, err + } + return parsed.Add(23*time.Hour + 59*time.Minute + 59*time.Second), nil +} + +func (a *App) adminUpdateMembership(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "用户编号无效") + return + } + var req struct { + Operation string `json:"operation"` + PlanID int64 `json:"planId"` + ExpiresAt string `json:"expiresAt"` + Reason string `json:"reason"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "会员设置格式错误") + return + } + if req.Operation == "" { + req.Operation = "grant" + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "会员设置失败") + return + } + defer func() { _ = tx.Rollback() }() + if req.Operation == "revoke" { + _, err = tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1`, id) + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET is_vip=0,vip_level=0 WHERE user_id=?`, id) + } + } else { + var durationDays, level int + if err = tx.QueryRowContext(r.Context(), `SELECT duration_days,level FROM membership_plans WHERE id=? AND deleted_at IS NULL`, req.PlanID).Scan(&durationDays, &level); err != nil { + fail(w, 400, 20001, "会员套餐不存在") + return + } + expiresAt, parseErr := parseAdminExpiry(req.ExpiresAt, durationDays) + if parseErr != nil || !expiresAt.After(time.Now()) { + fail(w, 400, 20001, "会员到期时间无效") + return + } + _, err = tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1`, id) + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(?,?,'admin',1,NOW(3),?)`, id, req.PlanID, expiresAt) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET is_vip=1,vip_level=? WHERE user_id=?`, level, id) + } + } + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "会员设置失败") + return + } + a.audit(r, "membership_"+req.Operation, "user", id, map[string]any{"planId": req.PlanID, "expiresAt": req.ExpiresAt, "reason": req.Reason}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) forceLogoutUser(ctx context.Context, userID, adminID int64, passwordReset bool) error { + if _, err := a.db.ExecContext(ctx, `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, userID); err != nil { + return err + } + if passwordReset { + _, err := a.db.ExecContext(ctx, `INSERT INTO user_security_controls(user_id,token_version,force_logout_at,password_reset_at,last_operator_admin_id) VALUES(?,1,NOW(3),NOW(3),?) ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=VALUES(force_logout_at),password_reset_at=VALUES(password_reset_at),last_operator_admin_id=VALUES(last_operator_admin_id)`, userID, adminID) + if err == nil { + a.hub.disconnect(userID) + } + return err + } + _, err := a.db.ExecContext(ctx, `INSERT INTO user_security_controls(user_id,token_version,force_logout_at,last_operator_admin_id) VALUES(?,1,NOW(3),?) ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=VALUES(force_logout_at),last_operator_admin_id=VALUES(last_operator_admin_id)`, userID, adminID) + if err == nil { + a.hub.disconnect(userID) + } + return err +} + +func (a *App) adminResetUserPassword(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + var req struct { + NewPassword string `json:"newPassword"` + } + if err != nil || decode(r, &req) != nil || !validUserPassword(req.NewPassword) { + fail(w, 400, 20001, "请填写有效的用户编号和新密码") + return + } + hash, err := hashPassword(req.NewPassword) + if err != nil { + fail(w, 500, 50001, "密码加密失败") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE users SET password_hash=? WHERE id=? AND deleted_at IS NULL`, hash, id) + if err != nil { + fail(w, 500, 50001, "重置密码失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 || a.forceLogoutUser(r.Context(), id, current(r).ID, true) != nil { + fail(w, 500, 50001, "重置密码失败") + return + } + a.audit(r, "reset_password", "user", id, map[string]any{"forceLogout": true}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminForceLogout(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil || a.forceLogoutUser(r.Context(), id, current(r).ID, false) != nil { + fail(w, 500, 50001, "强制下线失败") + return + } + a.audit(r, "force_logout", "user", id, nil) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) sanctionList(ctx context.Context, userID int64) []map[string]any { + items := []map[string]any{} + rows, err := a.db.QueryContext(ctx, `SELECT s.id,s.sanction_type,s.reason,s.starts_at,s.expires_at,s.status,s.operator_admin_id,COALESCE(a.real_name,''),s.revoked_at,s.created_at FROM user_sanctions s LEFT JOIN admin_users a ON a.id=s.operator_admin_id WHERE s.user_id=? ORDER BY s.created_at DESC LIMIT 100`, userID) + if err != nil { + return items + } + defer rows.Close() + for rows.Next() { + var id, operatorID int64 + var typ, reason, status, operatorName string + var startsAt, createdAt time.Time + var expiresAt, revokedAt any + _ = rows.Scan(&id, &typ, &reason, &startsAt, &expiresAt, &status, &operatorID, &operatorName, &revokedAt, &createdAt) + items = append(items, map[string]any{"id": id, "type": typ, "reason": reason, "startsAt": startsAt, "expiresAt": expiresAt, "status": status, "operatorId": operatorID, "operatorName": operatorName, "revokedAt": revokedAt, "createdAt": createdAt}) + } + return items +} + +func (a *App) createSanction(ctx context.Context, adminID, userID int64, typ, reason string, expiresAt *time.Time) (int64, error) { + result, err := a.db.ExecContext(ctx, `INSERT INTO user_sanctions(user_id,sanction_type,reason,expires_at,operator_admin_id) VALUES(?,?,?,?,?)`, userID, typ, reason, expiresAt, adminID) + if err != nil { + return 0, err + } + id, _ := result.LastInsertId() + if typ == "FREEZE" || typ == "BAN" { + status := 2 + if typ == "BAN" { + status = 3 + } + if _, err = a.db.ExecContext(ctx, `UPDATE users SET status=? WHERE id=?`, status, userID); err == nil { + err = a.forceLogoutUser(ctx, userID, adminID, false) + } + } + if err == nil { + title := map[string]string{"WARNING": "违规警告", "MUTE": "禁言通知", "CONTENT_LIMIT": "内容发布限制", "FREEZE": "账号冻结", "BAN": "账号封禁"}[typ] + a.notifyUser(ctx, userID, "system", title, reason, "sanction", nil) + } + return id, err +} + +func (a *App) adminUserSanctions(w http.ResponseWriter, r *http.Request) { + userID, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "用户编号无效") + return + } + if r.Method == http.MethodGet { + reply(w, map[string]any{"items": a.sanctionList(r.Context(), userID)}) + return + } + var req struct { + Type string `json:"type"` + Reason string `json:"reason"` + ExpiresAt string `json:"expiresAt"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "处罚信息格式错误") + return + } + req.Type = strings.ToUpper(strings.TrimSpace(req.Type)) + allowed := map[string]bool{"WARNING": true, "FREEZE": true, "BAN": true, "MUTE": true, "CONTENT_LIMIT": true} + if !allowed[req.Type] || strings.TrimSpace(req.Reason) == "" { + fail(w, 400, 20001, "请选择处罚类型并填写原因") + return + } + var expiry *time.Time + if req.ExpiresAt != "" { + parsed, parseErr := parseAdminExpiry(req.ExpiresAt, 0) + if parseErr != nil || !parsed.After(time.Now()) { + fail(w, 400, 20001, "处罚到期时间无效") + return + } + expiry = &parsed + } + sanctionID, err := a.createSanction(r.Context(), current(r).ID, userID, req.Type, strings.TrimSpace(req.Reason), expiry) + if err != nil { + fail(w, 500, 50001, "执行处罚失败") + return + } + a.audit(r, "sanction", "user", userID, map[string]any{"sanctionId": sanctionID, "type": req.Type, "reason": req.Reason, "expiresAt": req.ExpiresAt}) + reply(w, map[string]any{"id": sanctionID}) +} + +func (a *App) adminRevokeSanction(w http.ResponseWriter, r *http.Request) { + sanctionID, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "处罚编号无效") + return + } + var userID int64 + var typ string + if err = a.db.QueryRowContext(r.Context(), `SELECT user_id,sanction_type FROM user_sanctions WHERE id=? AND status='ACTIVE'`, sanctionID).Scan(&userID, &typ); err != nil { + fail(w, 404, 30001, "有效处罚不存在") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE user_sanctions SET status='REVOKED',revoked_by=?,revoked_at=NOW(3) WHERE id=? AND status='ACTIVE'`, current(r).ID, sanctionID) + affected, _ := result.RowsAffected() + if err != nil || affected == 0 { + fail(w, 500, 50001, "撤销处罚失败") + return + } + if typ == "FREEZE" || typ == "BAN" { + var bans, freezes int + _ = a.db.QueryRowContext(r.Context(), `SELECT SUM(sanction_type='BAN'),SUM(sanction_type='FREEZE') FROM user_sanctions WHERE user_id=? AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>NOW(3))`, userID).Scan(&bans, &freezes) + status := 1 + if bans > 0 { + status = 3 + } else if freezes > 0 { + status = 2 + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE users SET status=? WHERE id=?`, status, userID) + } + a.audit(r, "revoke_sanction", "user", userID, map[string]any{"sanctionId": sanctionID, "type": typ}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) isSanctionActive(ctx context.Context, userID int64, typ string) bool { + var count int + _ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_sanctions WHERE user_id=? AND sanction_type=? AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>NOW(3))`, userID, typ).Scan(&count) + return count > 0 +} + +func (a *App) normalizeUserStatus(ctx context.Context, userID int64, status int) int { + if status != 2 && status != 3 { + return status + } + var total int + _ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_sanctions WHERE user_id=? AND sanction_type IN ('FREEZE','BAN')`, userID).Scan(&total) + if total == 0 { + return status + } + _, _ = a.db.ExecContext(ctx, `UPDATE user_sanctions SET status='EXPIRED' WHERE user_id=? AND sanction_type IN ('FREEZE','BAN') AND status='ACTIVE' AND expires_at<=NOW(3)`, userID) + var bans, freezes int + _ = a.db.QueryRowContext(ctx, `SELECT SUM(sanction_type='BAN'),SUM(sanction_type='FREEZE') FROM user_sanctions WHERE user_id=? AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>NOW(3))`, userID).Scan(&bans, &freezes) + resolved := 1 + if bans > 0 { + resolved = 3 + } else if freezes > 0 { + resolved = 2 + } + if resolved != status { + _, _ = a.db.ExecContext(ctx, `UPDATE users SET status=? WHERE id=?`, resolved, userID) + } + return resolved +} + +func (a *App) recomputeMembershipTx(ctx context.Context, tx *sql.Tx, userID int64) error { + var level sql.NullInt64 + if err := tx.QueryRowContext(ctx, `SELECT MAX(p.level) FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id WHERE s.user_id=? AND s.status=1 AND s.expires_at>NOW(3)`, userID).Scan(&level); err != nil { + return err + } + if !level.Valid { + _, err := tx.ExecContext(ctx, `UPDATE user_profiles SET is_vip=0,vip_level=0 WHERE user_id=?`, userID) + return err + } + _, err := tx.ExecContext(ctx, `UPDATE user_profiles SET is_vip=1,vip_level=? WHERE user_id=?`, level.Int64, userID) + return err +} + +func (a *App) adminOrderTransition(w http.ResponseWriter, r *http.Request, action string) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "订单编号无效") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "订单操作失败") + return + } + defer func() { _ = tx.Rollback() }() + var userID, planID int64 + var status string + if err = tx.QueryRowContext(r.Context(), `SELECT user_id,product_id,status FROM orders WHERE id=? AND deleted_at IS NULL FOR UPDATE`, id).Scan(&userID, &planID, &status); err != nil { + fail(w, 404, 30001, "订单不存在") + return + } + switch action { + case "pay": + if status != "CREATED" { + fail(w, 400, 20001, "只有待支付订单可标记为已支付") + return + } + var durationDays, level int + if err = tx.QueryRowContext(r.Context(), `SELECT duration_days,level FROM membership_plans WHERE id=? AND deleted_at IS NULL`, planID).Scan(&durationDays, &level); err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='PAID',paid_at=NOW(3) WHERE id=?`, id) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(?,?,?,1,NOW(3),DATE_ADD(NOW(3),INTERVAL ? DAY))`, userID, planID, fmt.Sprintf("admin_order:%d", id), durationDays) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET is_vip=1,vip_level=GREATEST(vip_level,?) WHERE user_id=?`, level, userID) + } + case "close": + if status != "CREATED" { + fail(w, 400, 20001, "只有待支付订单可关闭") + return + } + _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='CLOSED' WHERE id=?`, id) + case "refund": + if status != "PAID" && status != "REFUND_REQUESTED" { + fail(w, 400, 20001, "只有已支付或用户已申请退款的订单可退款") + return + } + _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='REFUNDED' WHERE id=?`, id) + if err == nil { + result, updateErr := tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE user_id=? AND plan_id=? AND status=1 AND source IN (?,?)`, userID, planID, fmt.Sprintf("order:%d", id), fmt.Sprintf("admin_order:%d", id)) + err = updateErr + affected, _ := result.RowsAffected() + if err == nil && affected == 0 { + _, err = tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE id=(SELECT id FROM (SELECT id FROM subscriptions WHERE user_id=? AND plan_id=? AND status=1 ORDER BY started_at DESC LIMIT 1) latest)`, userID, planID) + } + } + if err == nil { + err = a.recomputeMembershipTx(r.Context(), tx, userID) + } + default: + fail(w, 400, 20001, "订单操作无效") + return + } + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "订单操作失败") + return + } + a.audit(r, action, "order", id, map[string]any{"userId": userID, "previousStatus": status}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminMarkOrderPaid(w http.ResponseWriter, r *http.Request) { + if a.configPlain(r.Context(), "payment.mode", "sandbox") == "live" { + fail(w, http.StatusBadRequest, 20001, "生产支付订单只能由已验签的支付回调确认入账") + return + } + a.adminOrderTransition(w, r, "pay") +} + +func (a *App) adminCloseOrder(w http.ResponseWriter, r *http.Request) { + a.adminOrderTransition(w, r, "close") +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/admin_rbac.go b/im/.deploy/application-20260831-120612/backend/internal/app/admin_rbac.go new file mode 100644 index 0000000..7459cbe --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/admin_rbac.go @@ -0,0 +1,525 @@ +package app + +import ( + "context" + "database/sql" + "net/http" + "regexp" + "sort" + "strings" + "time" +) + +type adminRoleView struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + BuiltIn bool `json:"builtIn"` + Status int `json:"status"` + Permissions []string `json:"permissions"` +} + +var adminAccountPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.-]{2,49}$`) +var adminRoleCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{2,49}$`) + +func (a *App) adminAccess(ctx context.Context, adminID int64) ([]string, []string, error) { + roleRows, err := a.db.QueryContext(ctx, `SELECT DISTINCT r.role_code FROM admin_user_roles ur JOIN admin_roles r ON r.id=ur.role_id WHERE ur.admin_user_id=? AND r.status=1 ORDER BY r.role_code`, adminID) + if err != nil { + return nil, nil, err + } + roles := []string{} + for roleRows.Next() { + var code string + if err = roleRows.Scan(&code); err != nil { + _ = roleRows.Close() + return nil, nil, err + } + roles = append(roles, code) + } + if err = roleRows.Close(); err != nil { + return nil, nil, err + } + + permissionRows, err := a.db.QueryContext(ctx, `SELECT DISTINCT rp.permission_code + FROM admin_user_roles ur + JOIN admin_roles r ON r.id=ur.role_id AND r.status=1 + JOIN admin_role_permissions rp ON rp.role_id=r.id + WHERE ur.admin_user_id=? ORDER BY rp.permission_code`, adminID) + if err != nil { + return nil, nil, err + } + permissions := []string{} + for permissionRows.Next() { + var code string + if err = permissionRows.Scan(&code); err != nil { + _ = permissionRows.Close() + return nil, nil, err + } + permissions = append(permissions, code) + } + err = permissionRows.Close() + return roles, permissions, err +} + +func (a *App) hasAdminPermission(ctx context.Context, adminID int64, permission string) bool { + var allowed int + err := a.db.QueryRowContext(ctx, `SELECT EXISTS( + SELECT 1 FROM admin_user_roles ur + JOIN admin_roles r ON r.id=ur.role_id AND r.status=1 + JOIN admin_role_permissions rp ON rp.role_id=r.id + WHERE ur.admin_user_id=? AND rp.permission_code=?)`, adminID, permission).Scan(&allowed) + return err == nil && allowed == 1 +} + +func (a *App) requireAdminPermission(permission string, next http.HandlerFunc) http.HandlerFunc { + return a.requireAuth("admin", func(w http.ResponseWriter, r *http.Request) { + if !a.hasAdminPermission(r.Context(), current(r).ID, permission) { + fail(w, http.StatusForbidden, 10006, "没有执行该操作的权限") + return + } + next(w, r) + }) +} + +func (a *App) adminCodes(w http.ResponseWriter, r *http.Request) { + _, permissions, err := a.adminAccess(r.Context(), current(r).ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取权限失败") + return + } + reply(w, permissions) +} + +func (a *App) adminRoleIDs(ctx context.Context, adminID int64) []int64 { + rows, err := a.db.QueryContext(ctx, `SELECT role_id FROM admin_user_roles WHERE admin_user_id=? ORDER BY role_id`, adminID) + if err != nil { + return []int64{} + } + defer rows.Close() + result := []int64{} + for rows.Next() { + var id int64 + if rows.Scan(&id) == nil { + result = append(result, id) + } + } + return result +} + +func (a *App) adminAccounts(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + keyword := strings.TrimSpace(r.URL.Query().Get("keyword")) + where := "" + args := []any{} + if keyword != "" { + where = ` WHERE username LIKE ? OR real_name LIKE ?` + like := "%" + keyword + "%" + args = append(args, like, like) + } + var total int64 + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM admin_users`+where, args...).Scan(&total); err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询管理员失败") + return + } + queryArgs := append(append([]any{}, args...), size, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT id,username,real_name,avatar_url,status,last_login_at,password_changed_at,created_at FROM admin_users`+where+` ORDER BY id LIMIT ? OFFSET ?`, queryArgs...) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询管理员失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var username, realName, avatar string + var status int + var lastLogin, passwordChanged sql.NullTime + var createdAt time.Time + if err = rows.Scan(&id, &username, &realName, &avatar, &status, &lastLogin, &passwordChanged, &createdAt); err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取管理员失败") + return + } + roles, _, _ := a.adminAccess(r.Context(), id) + items = append(items, map[string]any{ + "id": id, "username": username, "realName": realName, "avatar": avatar, + "status": status, "roles": roles, "roleIds": a.adminRoleIDs(r.Context(), id), + "lastLoginAt": nullableTime(lastLogin), "passwordChangedAt": nullableTime(passwordChanged), "createdAt": createdAt, + }) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} + +func validateAdminRoleIDs(ctx context.Context, tx *sql.Tx, roleIDs []int64) error { + if len(roleIDs) == 0 { + return sql.ErrNoRows + } + seen := map[int64]bool{} + for _, roleID := range roleIDs { + if roleID <= 0 || seen[roleID] { + return sql.ErrNoRows + } + seen[roleID] = true + var status int + if err := tx.QueryRowContext(ctx, `SELECT status FROM admin_roles WHERE id=?`, roleID).Scan(&status); err != nil || status != 1 { + return sql.ErrNoRows + } + } + return nil +} + +func replaceAdminRoles(ctx context.Context, tx *sql.Tx, adminID int64, roleIDs []int64) error { + if err := validateAdminRoleIDs(ctx, tx, roleIDs); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM admin_user_roles WHERE admin_user_id=?`, adminID); err != nil { + return err + } + for _, roleID := range roleIDs { + if _, err := tx.ExecContext(ctx, `INSERT INTO admin_user_roles(admin_user_id,role_id) VALUES(?,?)`, adminID, roleID); err != nil { + return err + } + } + return nil +} + +func (a *App) adminCreateAccount(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + RealName string `json:"realName"` + Password string `json:"password"` + Avatar string `json:"avatar"` + RoleIDs []int64 `json:"roleIds"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "管理员资料格式错误") + return + } + req.Username = strings.TrimSpace(req.Username) + req.RealName = strings.TrimSpace(req.RealName) + if !adminAccountPattern.MatchString(req.Username) || req.RealName == "" || len([]rune(req.RealName)) > 50 { + fail(w, http.StatusBadRequest, 20001, "账号或姓名格式无效") + return + } + if req.Password == "" { + fail(w, http.StatusBadRequest, 20001, "请输入密码") + return + } + hash, err := hashPassword(req.Password) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "密码加密失败") + return + } + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建管理员失败") + return + } + defer func() { _ = tx.Rollback() }() + if err = validateAdminRoleIDs(r.Context(), tx, req.RoleIDs); err != nil { + fail(w, http.StatusBadRequest, 20001, "请至少选择一个有效角色") + return + } + result, err := tx.ExecContext(r.Context(), `INSERT INTO admin_users(username,password_hash,real_name,avatar_url,status,password_changed_at) VALUES(?,?,?,?,1,NOW(3))`, req.Username, hash, req.RealName, strings.TrimSpace(req.Avatar)) + if err != nil { + fail(w, http.StatusConflict, 20001, "管理员账号已存在") + return + } + adminID, _ := result.LastInsertId() + if err = replaceAdminRoles(r.Context(), tx, adminID, req.RoleIDs); err != nil || tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "保存管理员角色失败") + return + } + a.audit(r, "create", "admin_user", adminID, map[string]any{"username": req.Username, "roleIds": req.RoleIDs}) + reply(w, map[string]any{"id": adminID, "success": true}) +} + +func containsInt64(values []int64, expected int64) bool { + for _, value := range values { + if value == expected { + return true + } + } + return false +} + +func (a *App) ensureSuperAdminRemains(ctx context.Context, tx *sql.Tx, targetID int64, newStatus int, roleIDs []int64) error { + var superRoleID int64 + if err := tx.QueryRowContext(ctx, `SELECT id FROM admin_roles WHERE role_code='super_admin'`).Scan(&superRoleID); err != nil { + return err + } + var targetWasSuper int + if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM admin_user_roles WHERE admin_user_id=? AND role_id=?)`, targetID, superRoleID).Scan(&targetWasSuper); err != nil { + return err + } + if targetWasSuper == 0 || (newStatus == 1 && containsInt64(roleIDs, superRoleID)) { + return nil + } + var remaining int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT a.id) FROM admin_users a JOIN admin_user_roles ur ON ur.admin_user_id=a.id WHERE a.status=1 AND a.id<>? AND ur.role_id=?`, targetID, superRoleID).Scan(&remaining); err != nil { + return err + } + if remaining == 0 { + return sql.ErrNoRows + } + return nil +} + +func (a *App) adminUpdateAccount(w http.ResponseWriter, r *http.Request) { + adminID, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "管理员 ID 无效") + return + } + var req struct { + RealName string `json:"realName"` + Avatar string `json:"avatar"` + Password string `json:"password"` + Status int `json:"status"` + RoleIDs []int64 `json:"roleIds"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.RealName) == "" || (req.Status != 0 && req.Status != 1) { + fail(w, http.StatusBadRequest, 20001, "管理员资料格式错误") + return + } + if current(r).ID == adminID && req.Status != 1 { + fail(w, http.StatusBadRequest, 20001, "不能停用当前登录账号") + return + } + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新管理员失败") + return + } + defer func() { _ = tx.Rollback() }() + var exists int64 + if err = tx.QueryRowContext(r.Context(), `SELECT id FROM admin_users WHERE id=? FOR UPDATE`, adminID).Scan(&exists); err != nil { + fail(w, http.StatusNotFound, 30001, "管理员不存在") + return + } + if err = validateAdminRoleIDs(r.Context(), tx, req.RoleIDs); err != nil { + fail(w, http.StatusBadRequest, 20001, "请至少选择一个有效角色") + return + } + if err = a.ensureSuperAdminRemains(r.Context(), tx, adminID, req.Status, req.RoleIDs); err != nil { + fail(w, http.StatusBadRequest, 20001, "必须保留至少一个启用的超级管理员") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_users SET real_name=?,avatar_url=?,status=? WHERE id=?`, strings.TrimSpace(req.RealName), strings.TrimSpace(req.Avatar), req.Status, adminID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新管理员失败") + return + } + if err = replaceAdminRoles(r.Context(), tx, adminID, req.RoleIDs); err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新管理员角色失败") + return + } + passwordChanged := false + if req.Password != "" { + hash, hashErr := hashPassword(req.Password) + if hashErr != nil { + fail(w, http.StatusInternalServerError, 50001, "密码加密失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_users SET password_hash=?,token_version=token_version+1,password_changed_at=NOW(3) WHERE id=?`, hash, adminID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新管理员密码失败") + return + } + passwordChanged = true + } + if req.Status != 1 || passwordChanged { + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_sessions SET revoked_at=NOW(3) WHERE admin_user_id=? AND revoked_at IS NULL`, adminID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "撤销管理员会话失败") + return + } + } + if err = tx.Commit(); err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新管理员失败") + return + } + a.audit(r, "update", "admin_user", adminID, map[string]any{"roleIds": req.RoleIDs, "status": req.Status, "passwordChanged": passwordChanged}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) rolePermissions(ctx context.Context, roleID int64) []string { + rows, err := a.db.QueryContext(ctx, `SELECT permission_code FROM admin_role_permissions WHERE role_id=? ORDER BY permission_code`, roleID) + if err != nil { + return []string{} + } + defer rows.Close() + permissions := []string{} + for rows.Next() { + var code string + if rows.Scan(&code) == nil { + permissions = append(permissions, code) + } + } + return permissions +} + +func (a *App) adminRoles(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,role_code,role_name,description,built_in,status FROM admin_roles ORDER BY built_in DESC,id`) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询角色失败") + return + } + defer rows.Close() + items := []adminRoleView{} + for rows.Next() { + var item adminRoleView + var builtIn int + if err = rows.Scan(&item.ID, &item.Code, &item.Name, &item.Description, &builtIn, &item.Status); err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取角色失败") + return + } + item.BuiltIn = builtIn == 1 + item.Permissions = a.rolePermissions(r.Context(), item.ID) + items = append(items, item) + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) adminPermissions(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT permission_code,permission_name,permission_group FROM admin_permissions ORDER BY permission_group,sort_order,permission_code`) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询权限失败") + return + } + defer rows.Close() + items := []map[string]string{} + for rows.Next() { + var code, name, group string + if rows.Scan(&code, &name, &group) == nil { + items = append(items, map[string]string{"code": code, "name": name, "group": group}) + } + } + reply(w, map[string]any{"items": items}) +} + +func validatePermissionCodes(ctx context.Context, tx *sql.Tx, permissions []string) error { + if len(permissions) == 0 { + return sql.ErrNoRows + } + seen := map[string]bool{} + for _, permission := range permissions { + permission = strings.TrimSpace(permission) + if permission == "" || seen[permission] { + return sql.ErrNoRows + } + seen[permission] = true + var exists int + if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM admin_permissions WHERE permission_code=?)`, permission).Scan(&exists); err != nil || exists != 1 { + return sql.ErrNoRows + } + } + return nil +} + +func replaceRolePermissions(ctx context.Context, tx *sql.Tx, roleID int64, permissions []string) error { + if err := validatePermissionCodes(ctx, tx, permissions); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM admin_role_permissions WHERE role_id=?`, roleID); err != nil { + return err + } + sort.Strings(permissions) + for _, permission := range permissions { + if _, err := tx.ExecContext(ctx, `INSERT INTO admin_role_permissions(role_id,permission_code) VALUES(?,?)`, roleID, permission); err != nil { + return err + } + } + return nil +} + +func (a *App) adminCreateRole(w http.ResponseWriter, r *http.Request) { + a.adminSaveRole(w, r, 0) +} + +func (a *App) adminUpdateRole(w http.ResponseWriter, r *http.Request) { + roleID, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "角色 ID 无效") + return + } + a.adminSaveRole(w, r, roleID) +} + +func (a *App) adminSaveRole(w http.ResponseWriter, r *http.Request, roleID int64) { + var req struct { + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + Permissions []string `json:"permissions"` + Status int `json:"status"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "角色资料格式错误") + return + } + req.Code = strings.TrimSpace(req.Code) + req.Name = strings.TrimSpace(req.Name) + if !adminRoleCodePattern.MatchString(req.Code) || req.Name == "" || len([]rune(req.Name)) > 80 || (req.Status != 0 && req.Status != 1) { + fail(w, http.StatusBadRequest, 20001, "角色编码、名称或状态无效") + return + } + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存角色失败") + return + } + defer func() { _ = tx.Rollback() }() + if err = validatePermissionCodes(r.Context(), tx, req.Permissions); err != nil { + fail(w, http.StatusBadRequest, 20001, "请至少选择一个有效权限") + return + } + if roleID == 0 { + result, insertErr := tx.ExecContext(r.Context(), `INSERT INTO admin_roles(role_code,role_name,description,status) VALUES(?,?,?,?)`, req.Code, req.Name, strings.TrimSpace(req.Description), req.Status) + if insertErr != nil { + fail(w, http.StatusConflict, 20001, "角色编码已存在") + return + } + roleID, _ = result.LastInsertId() + } else { + var builtIn int + if err = tx.QueryRowContext(r.Context(), `SELECT built_in FROM admin_roles WHERE id=? FOR UPDATE`, roleID).Scan(&builtIn); err != nil { + fail(w, http.StatusNotFound, 30001, "角色不存在") + return + } + if builtIn == 1 { + fail(w, http.StatusBadRequest, 20001, "内置超级管理员角色不可修改") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_roles SET role_code=?,role_name=?,description=?,status=? WHERE id=?`, req.Code, req.Name, strings.TrimSpace(req.Description), req.Status, roleID); err != nil { + fail(w, http.StatusConflict, 20001, "角色编码已存在") + return + } + } + if err = replaceRolePermissions(r.Context(), tx, roleID, req.Permissions); err != nil || tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "保存角色权限失败") + return + } + a.audit(r, "save", "admin_role", roleID, map[string]any{"code": req.Code, "permissions": req.Permissions, "status": req.Status}) + reply(w, map[string]any{"id": roleID, "success": true}) +} + +func (a *App) adminDeleteRole(w http.ResponseWriter, r *http.Request) { + roleID, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "角色 ID 无效") + return + } + var builtIn, assigned int + if err = a.db.QueryRowContext(r.Context(), `SELECT built_in,(SELECT COUNT(*) FROM admin_user_roles WHERE role_id=admin_roles.id) FROM admin_roles WHERE id=?`, roleID).Scan(&builtIn, &assigned); err != nil { + fail(w, http.StatusNotFound, 30001, "角色不存在") + return + } + if builtIn == 1 || assigned > 0 { + fail(w, http.StatusBadRequest, 20001, "内置角色或仍被管理员使用的角色不能删除") + return + } + if _, err = a.db.ExecContext(r.Context(), `DELETE FROM admin_roles WHERE id=?`, roleID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "删除角色失败") + return + } + a.audit(r, "delete", "admin_role", roleID, nil) + reply(w, map[string]bool{"success": true}) +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/admin_sessions.go b/im/.deploy/application-20260831-120612/backend/internal/app/admin_sessions.go new file mode 100644 index 0000000..44b157f --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/admin_sessions.go @@ -0,0 +1,183 @@ +package app + +import ( + "context" + "crypto/sha256" + "database/sql" + "errors" + "net/http" + "strings" + "time" +) + +const ( + adminAccessTokenTTL = 30 * time.Minute + adminRefreshTokenTTL = 7 * 24 * time.Hour + adminRefreshCookieKey = "xingyu_admin_refresh" +) + +func adminUserAgentHash(r *http.Request) []byte { + sum := sha256.Sum256([]byte(strings.TrimSpace(r.UserAgent()))) + return sum[:] +} + +func (a *App) setAdminRefreshCookie(w http.ResponseWriter, token string, expiresAt time.Time) { + maxAge := int(time.Until(expiresAt).Seconds()) + if maxAge < 0 { + maxAge = 0 + } + sameSite := http.SameSiteLaxMode + secure := a.config.Environment == "production" + if secure { + // Production supports a dedicated HTTPS API domain while keeping the + // refresh token inaccessible to JavaScript. + sameSite = http.SameSiteNoneMode + } + http.SetCookie(w, &http.Cookie{ + Name: adminRefreshCookieKey, + Value: token, + Path: "/admin/v1/auth", + Expires: expiresAt, + MaxAge: maxAge, + HttpOnly: true, + Secure: secure, + SameSite: sameSite, + }) +} + +func (a *App) clearAdminRefreshCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: adminRefreshCookieKey, + Value: "", + Path: "/admin/v1/auth", + Expires: time.Unix(1, 0), + MaxAge: -1, + HttpOnly: true, + Secure: a.config.Environment == "production", + SameSite: func() http.SameSite { + if a.config.Environment == "production" { + return http.SameSiteNoneMode + } + return http.SameSiteLaxMode + }(), + }) +} + +func (a *App) adminRefreshTokenFromRequest(r *http.Request) string { + if cookie, err := r.Cookie(adminRefreshCookieKey); err == nil { + if token := strings.TrimSpace(cookie.Value); token != "" { + return token + } + } + if r.Body == nil || r.ContentLength == 0 { + return "" + } + var req struct { + RefreshToken string `json:"refreshToken"` + } + if decode(r, &req) != nil { + return "" + } + return strings.TrimSpace(req.RefreshToken) +} + +func (a *App) newAdminSession(ctx context.Context, w http.ResponseWriter, r *http.Request, adminID int64, realName string) (map[string]any, error) { + refreshToken := randomToken() + refreshHash := sha256.Sum256([]byte(refreshToken)) + expiresAt := time.Now().Add(adminRefreshTokenTTL) + if _, err := a.db.ExecContext(ctx, `INSERT INTO admin_sessions(admin_user_id,refresh_token_hash,user_agent_hash,ip,expires_at) VALUES(?,?,?,?,?)`, adminID, refreshHash[:], adminUserAgentHash(r), clientIP(r), expiresAt); err != nil { + return nil, err + } + accessToken, err := a.token(adminID, "admin", realName, adminAccessTokenTTL) + if err != nil { + _, _ = a.db.ExecContext(ctx, `UPDATE admin_sessions SET revoked_at=NOW(3) WHERE refresh_token_hash=?`, refreshHash[:]) + return nil, err + } + a.setAdminRefreshCookie(w, refreshToken, expiresAt) + result := map[string]any{"accessToken": accessToken, "expiresIn": int(adminAccessTokenTTL.Seconds())} + if a.config.Environment != "production" { + // HTTP development across localhost/127.0.0.1 cannot use a SameSite=None + // cookie. The fallback is sessionStorage-only on the admin client. + result["refreshToken"] = refreshToken + } + return result, nil +} + +func (a *App) rotateAdminSession(ctx context.Context, w http.ResponseWriter, r *http.Request, refreshToken string) (map[string]any, error) { + oldHash := sha256.Sum256([]byte(refreshToken)) + tx, err := a.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + + var sessionID, adminID int64 + var realName string + var status int + var expiresAt time.Time + err = tx.QueryRowContext(ctx, `SELECT s.id,a.id,a.real_name,a.status,s.expires_at + FROM admin_sessions s JOIN admin_users a ON a.id=s.admin_user_id + WHERE s.refresh_token_hash=? AND s.revoked_at IS NULL AND s.expires_at>NOW(3) + FOR UPDATE`, oldHash[:]).Scan(&sessionID, &adminID, &realName, &status, &expiresAt) + if err != nil || status != 1 { + return nil, errors.New("刷新令牌无效或已过期") + } + + newRefreshToken := randomToken() + newHash := sha256.Sum256([]byte(newRefreshToken)) + result, err := tx.ExecContext(ctx, `UPDATE admin_sessions SET refresh_token_hash=?,user_agent_hash=?,ip=?,last_active_at=NOW(3) WHERE id=? AND refresh_token_hash=? AND revoked_at IS NULL`, newHash[:], adminUserAgentHash(r), clientIP(r), sessionID, oldHash[:]) + if err != nil { + return nil, err + } + affected, _ := result.RowsAffected() + if affected != 1 { + return nil, errors.New("刷新令牌已被使用") + } + if err = tx.Commit(); err != nil { + return nil, err + } + + accessToken, err := a.token(adminID, "admin", realName, adminAccessTokenTTL) + if err != nil { + return nil, err + } + a.setAdminRefreshCookie(w, newRefreshToken, expiresAt) + payload := map[string]any{"accessToken": accessToken, "expiresIn": int(adminAccessTokenTTL.Seconds())} + if a.config.Environment != "production" { + payload["refreshToken"] = newRefreshToken + } + return payload, nil +} + +func (a *App) revokeAdminSession(ctx context.Context, refreshToken string) { + if strings.TrimSpace(refreshToken) == "" { + return + } + hash := sha256.Sum256([]byte(refreshToken)) + _, _ = a.db.ExecContext(ctx, `UPDATE admin_sessions SET revoked_at=NOW(3) WHERE refresh_token_hash=? AND revoked_at IS NULL`, hash[:]) +} + +func (a *App) adminRefresh(w http.ResponseWriter, r *http.Request) { + if !a.rateLimit(w, r, "admin_refresh", clientIP(r), 120, 10*time.Minute) { + return + } + refreshToken := a.adminRefreshTokenFromRequest(r) + if refreshToken == "" { + a.clearAdminRefreshCookie(w) + fail(w, http.StatusUnauthorized, 10001, "刷新令牌不存在") + return + } + payload, err := a.rotateAdminSession(r.Context(), w, r, refreshToken) + if err != nil { + a.clearAdminRefreshCookie(w) + fail(w, http.StatusUnauthorized, 10001, err.Error()) + return + } + reply(w, payload) +} + +func (a *App) adminLogout(w http.ResponseWriter, r *http.Request) { + a.revokeAdminSession(r.Context(), a.adminRefreshTokenFromRequest(r)) + a.clearAdminRefreshCookie(w) + reply(w, map[string]bool{"success": true}) +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/admin_sessions_test.go b/im/.deploy/application-20260831-120612/backend/internal/app/admin_sessions_test.go new file mode 100644 index 0000000..9b84792 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/admin_sessions_test.go @@ -0,0 +1,42 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestAdminRefreshCookieSecurityModes(t *testing.T) { + production := &App{config: Config{Environment: "production"}} + recorder := httptest.NewRecorder() + production.setAdminRefreshCookie(recorder, "token", time.Now().Add(time.Hour)) + cookies := recorder.Result().Cookies() + if len(cookies) != 1 || !cookies[0].HttpOnly || !cookies[0].Secure || cookies[0].SameSite != http.SameSiteNoneMode { + t.Fatalf("unexpected production cookie: %#v", cookies) + } + + development := &App{config: Config{Environment: "development"}} + recorder = httptest.NewRecorder() + development.setAdminRefreshCookie(recorder, "token", time.Now().Add(time.Hour)) + cookies = recorder.Result().Cookies() + if len(cookies) != 1 || cookies[0].Secure || cookies[0].SameSite != http.SameSiteLaxMode { + t.Fatalf("unexpected development cookie: %#v", cookies) + } +} + +func TestAdminRefreshTokenRequestFallback(t *testing.T) { + application := &App{} + request := httptest.NewRequest(http.MethodPost, "/admin/v1/auth/refresh", strings.NewReader(`{"refreshToken":"body-token"}`)) + request.Header.Set("Content-Type", "application/json") + if token := application.adminRefreshTokenFromRequest(request); token != "body-token" { + t.Fatalf("unexpected body token %q", token) + } + + request = httptest.NewRequest(http.MethodPost, "/admin/v1/auth/refresh", strings.NewReader(`{"refreshToken":"body-token"}`)) + request.AddCookie(&http.Cookie{Name: adminRefreshCookieKey, Value: "cookie-token"}) + if token := application.adminRefreshTokenFromRequest(request); token != "cookie-token" { + t.Fatalf("cookie token must take priority, got %q", token) + } +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/app.go b/im/.deploy/application-20260831-120612/backend/internal/app/app.go new file mode 100644 index 0000000..80c6f32 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/app.go @@ -0,0 +1,475 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "regexp" + "strconv" + "strings" + "time" + + _ "github.com/go-sql-driver/mysql" + "github.com/zeromicro/go-zero/rest" +) + +type Config struct { + Host string + Port int + DSN string + JWTSecret string + ConfigEncryptionKey string + MediaDir string + Environment string + AllowedOrigins []string + SeedDemo bool + BootstrapAdminUsername string + BootstrapAdminPassword string + BootstrapAdminRealName string +} + +type App struct { + config Config + db *sql.DB + hub *Hub +} + +type apiResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data"` + RequestID string `json:"request_id,omitempty"` +} + +type pageResult struct { + Items any `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + Size int `json:"size"` +} + +func LoadConfig() Config { + port, _ := strconv.Atoi(env("IM_PORT", "8888")) + return Config{ + Host: env("IM_HOST", "0.0.0.0"), + Port: port, + DSN: env("IM_DB_DSN", "root:root@tcp(127.0.0.1:3306)/im?charset=utf8mb4&parseTime=True&loc=Local"), + JWTSecret: env("IM_JWT_SECRET", "local-development-secret-change-me"), + ConfigEncryptionKey: env("IM_CONFIG_ENCRYPTION_KEY", ""), + MediaDir: env("IM_MEDIA_DIR", "./uploads"), + Environment: strings.ToLower(env("IM_ENV", "development")), + AllowedOrigins: csvEnv("IM_ALLOWED_ORIGINS", "http://localhost:5173,http://localhost:5174,http://localhost:5180,http://localhost:5555,http://localhost:5556,http://localhost:5560,http://127.0.0.1:5173,http://127.0.0.1:5174,http://127.0.0.1:5180,http://127.0.0.1:5555,http://127.0.0.1:5556,http://127.0.0.1:5560"), + SeedDemo: boolEnv("IM_SEED_DEMO", false), + BootstrapAdminUsername: strings.TrimSpace(os.Getenv("IM_BOOTSTRAP_ADMIN_USERNAME")), + BootstrapAdminPassword: os.Getenv("IM_BOOTSTRAP_ADMIN_PASSWORD"), + BootstrapAdminRealName: env("IM_BOOTSTRAP_ADMIN_REAL_NAME", "平台管理员"), + } +} + +func New(config Config) (*App, error) { + if err := validateConfig(config); err != nil { + return nil, err + } + db, err := sql.Open("mysql", config.DSN) + if err != nil { + return nil, fmt.Errorf("open mysql: %w", err) + } + db.SetMaxOpenConns(30) + db.SetMaxIdleConns(10) + // Keep pooled connections below the local MySQL wait_timeout (120s). + db.SetConnMaxIdleTime(30 * time.Second) + db.SetConnMaxLifetime(90 * time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, fmt.Errorf("connect mysql (run scripts/migrate.ps1 first): %w", err) + } + return &App{config: config, db: db, hub: NewHub()}, nil +} + +func (a *App) Close() { _ = a.db.Close() } + +func (a *App) Run() { + workerContext, stopWorkers := context.WithCancel(context.Background()) + defer stopWorkers() + go func() { + a.processDueAccountClosures(workerContext) + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-workerContext.Done(): + return + case <-ticker.C: + a.processDueAccountClosures(workerContext) + } + } + }() + server := rest.MustNewServer(rest.RestConf{ + Host: a.config.Host, + Port: a.config.Port, + MaxBytes: 16 << 20, + MaxConns: 5_000, + Timeout: 35_000, + }, rest.WithCors(a.config.AllowedOrigins...)) + defer server.Stop() + server.Use(a.requestMetadata) + server.AddRoutes(a.routes()) + log.Printf("星遇 API listening on http://127.0.0.1:%d", a.config.Port) + server.Start() +} + +type responseRecorder struct { + http.ResponseWriter + status int +} + +func (r *responseRecorder) WriteHeader(status int) { + r.status = status + r.ResponseWriter.WriteHeader(status) +} + +var safeRequestID = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`) + +func (a *App) requestMetadata(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + started := time.Now() + requestID := strings.TrimSpace(r.Header.Get("X-Request-ID")) + if !safeRequestID.MatchString(requestID) { + requestID = randomToken()[:32] + } + w.Header().Set("X-Request-ID", requestID) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/admin/") { + w.Header().Set("Cache-Control", "no-store") + } + if r.URL.Path == "/ws" { + next(w, r) + log.Printf("request_id=%s method=%s path=%s status=%d duration_ms=%d ip=%s", requestID, r.Method, r.URL.Path, http.StatusSwitchingProtocols, time.Since(started).Milliseconds(), clientIP(r)) + return + } + recorder := &responseRecorder{ResponseWriter: w, status: http.StatusOK} + next(recorder, r) + log.Printf("request_id=%s method=%s path=%s status=%d duration_ms=%d ip=%s", requestID, r.Method, r.URL.Path, recorder.status, time.Since(started).Milliseconds(), clientIP(r)) + } +} + +func (a *App) routes() []rest.Route { + routes := []rest.Route{ + {Method: http.MethodGet, Path: "/healthz", Handler: a.health}, + {Method: http.MethodPost, Path: "/api/v1/auth/sms/send", Handler: a.sendSMS}, + {Method: http.MethodPost, Path: "/api/v1/auth/register", Handler: a.register}, + {Method: http.MethodPost, Path: "/api/v1/auth/login/password", Handler: a.loginPassword}, + {Method: http.MethodPost, Path: "/api/v1/auth/login/sms", Handler: a.loginSMS}, + {Method: http.MethodGet, Path: "/api/v1/auth/oauth/providers", Handler: a.userOAuthProviders}, + {Method: http.MethodPost, Path: "/api/v1/auth/oauth/start", Handler: a.userOAuthStart}, + {Method: http.MethodPost, Path: "/api/v1/auth/oauth/native", Handler: a.userOAuthNative}, + {Method: http.MethodGet, Path: "/api/v1/auth/oauth/callback", Handler: a.oauthCallback}, + {Method: http.MethodPost, Path: "/api/v1/auth/oauth/exchange", Handler: a.userOAuthExchange}, + {Method: http.MethodPost, Path: "/api/v1/auth/oauth/link", Handler: a.userOAuthLink}, + {Method: http.MethodPost, Path: "/api/v1/auth/password/reset", Handler: a.resetPassword}, + {Method: http.MethodPost, Path: "/api/v1/auth/token/refresh", Handler: a.refreshToken}, + {Method: http.MethodGet, Path: "/api/v1/membership/plans", Handler: a.membershipPlans}, + {Method: http.MethodGet, Path: "/api/v1/payment/channels", Handler: a.paymentChannels}, + {Method: http.MethodPost, Path: "/api/v1/payment/notify", Handler: a.paymentNotify}, + {Method: http.MethodGet, Path: "/api/v1/app/config", Handler: a.appConfig}, + {Method: http.MethodGet, Path: "/uploads/:name", Handler: a.serveMedia}, + {Method: http.MethodHead, Path: "/uploads/:name", Handler: a.serveMedia}, + {Method: http.MethodPost, Path: "/admin/v1/auth/login", Handler: a.adminLogin}, + {Method: http.MethodPost, Path: "/admin/v1/auth/refresh", Handler: a.adminRefresh}, + {Method: http.MethodPost, Path: "/admin/v1/auth/logout", Handler: a.adminLogout}, + {Method: http.MethodGet, Path: "/admin/v1/auth/oauth/providers", Handler: a.adminOAuthProviders}, + {Method: http.MethodPost, Path: "/admin/v1/auth/oauth/start", Handler: a.adminOAuthStart}, + {Method: http.MethodGet, Path: "/admin/v1/auth/oauth/callback", Handler: a.oauthCallback}, + {Method: http.MethodPost, Path: "/admin/v1/auth/oauth/exchange", Handler: a.adminOAuthExchange}, + {Method: http.MethodPost, Path: "/admin/v1/auth/oauth/link", Handler: a.adminOAuthLink}, + } + routes = append(routes, a.userRoutes()...) + routes = append(routes, a.adminRoutes()...) + return routes +} + +func (a *App) userRoutes() []rest.Route { + auth := func(next http.HandlerFunc) http.HandlerFunc { return a.requireAuth("user", next) } + return []rest.Route{ + {Method: http.MethodPost, Path: "/api/v1/auth/logout", Handler: auth(a.logout)}, + {Method: http.MethodGet, Path: "/api/v1/me", Handler: auth(a.me)}, + {Method: http.MethodGet, Path: "/api/v1/me/profile", Handler: auth(a.me)}, + {Method: http.MethodPatch, Path: "/api/v1/me/profile", Handler: auth(a.updateProfile)}, + {Method: http.MethodPut, Path: "/api/v1/me/password", Handler: auth(a.changeUserPassword)}, + {Method: http.MethodPut, Path: "/api/v1/me/phone", Handler: auth(a.changeUserPhone)}, + {Method: http.MethodGet, Path: "/api/v1/me/devices", Handler: auth(a.myDevices)}, + {Method: http.MethodDelete, Path: "/api/v1/me/devices/:id", Handler: auth(a.revokeDevice)}, + {Method: http.MethodGet, Path: "/api/v1/me/blocks", Handler: auth(a.blockedUsers)}, + {Method: http.MethodGet, Path: "/api/v1/me/notification-settings", Handler: auth(a.notificationSettings)}, + {Method: http.MethodPut, Path: "/api/v1/me/notification-settings", Handler: auth(a.updateNotificationSettings)}, + {Method: http.MethodPost, Path: "/api/v1/me/push-tokens", Handler: auth(a.registerPushToken)}, + {Method: http.MethodDelete, Path: "/api/v1/me/push-tokens", Handler: auth(a.deletePushToken)}, + {Method: http.MethodGet, Path: "/api/v1/me/feedback", Handler: auth(a.feedback)}, + {Method: http.MethodPost, Path: "/api/v1/me/feedback", Handler: auth(a.feedback)}, + {Method: http.MethodGet, Path: "/api/v1/me/account-closure", Handler: auth(a.accountClosure)}, + {Method: http.MethodPost, Path: "/api/v1/me/account-closure", Handler: auth(a.accountClosure)}, + {Method: http.MethodDelete, Path: "/api/v1/me/account-closure", Handler: auth(a.accountClosure)}, + {Method: http.MethodGet, Path: "/api/v1/me/data-export", Handler: auth(a.exportMyData)}, + {Method: http.MethodPost, Path: "/api/v1/me/consents", Handler: auth(a.recordConsent)}, + {Method: http.MethodGet, Path: "/api/v1/users/search", Handler: auth(a.searchUsers)}, + {Method: http.MethodGet, Path: "/api/v1/tags", Handler: auth(a.availableTags)}, + {Method: http.MethodGet, Path: "/api/v1/users/:id", Handler: auth(a.userProfile)}, + {Method: http.MethodGet, Path: "/api/v1/me/following", Handler: auth(a.followingList)}, + {Method: http.MethodGet, Path: "/api/v1/me/followers", Handler: auth(a.followerList)}, + {Method: http.MethodGet, Path: "/api/v1/me/visitors", Handler: auth(a.visitorList)}, + {Method: http.MethodGet, Path: "/api/v1/me/privacy", Handler: auth(a.getPrivacy)}, + {Method: http.MethodPut, Path: "/api/v1/me/privacy", Handler: auth(a.updatePrivacy)}, + {Method: http.MethodGet, Path: "/api/v1/me/verification", Handler: auth(a.myVerification)}, + {Method: http.MethodPost, Path: "/api/v1/me/verification", Handler: auth(a.submitVerification)}, + {Method: http.MethodGet, Path: "/api/v1/discover/recommendations", Handler: auth(a.discover)}, + {Method: http.MethodGet, Path: "/api/v1/nearby/users", Handler: auth(a.nearby)}, + {Method: http.MethodPut, Path: "/api/v1/location", Handler: auth(a.updateLocation)}, + {Method: http.MethodPost, Path: "/api/v1/users/:id/follow", Handler: auth(a.follow)}, + {Method: http.MethodDelete, Path: "/api/v1/users/:id/follow", Handler: auth(a.unfollow)}, + {Method: http.MethodPost, Path: "/api/v1/users/:id/like", Handler: auth(a.likeUser)}, + {Method: http.MethodDelete, Path: "/api/v1/users/:id/like", Handler: auth(a.unlikeUser)}, + {Method: http.MethodPost, Path: "/api/v1/users/:id/block", Handler: auth(a.blockUser)}, + {Method: http.MethodDelete, Path: "/api/v1/users/:id/block", Handler: auth(a.unblockUser)}, + {Method: http.MethodGet, Path: "/api/v1/feed", Handler: auth(a.feed)}, + {Method: http.MethodGet, Path: "/api/v1/posts/:id", Handler: auth(a.postDetail)}, + {Method: http.MethodGet, Path: "/api/v1/users/:id/posts", Handler: auth(a.userPosts)}, + {Method: http.MethodPost, Path: "/api/v1/posts", Handler: auth(a.createPost)}, + {Method: http.MethodPatch, Path: "/api/v1/posts/:id", Handler: auth(a.updatePost)}, + {Method: http.MethodDelete, Path: "/api/v1/posts/:id", Handler: auth(a.deleteOwnPost)}, + {Method: http.MethodPost, Path: "/api/v1/media/upload", Handler: auth(a.uploadMedia)}, + {Method: http.MethodPost, Path: "/api/v1/posts/:id/like", Handler: auth(a.likePost)}, + {Method: http.MethodDelete, Path: "/api/v1/posts/:id/like", Handler: auth(a.unlikePost)}, + {Method: http.MethodGet, Path: "/api/v1/posts/:id/comments", Handler: auth(a.comments)}, + {Method: http.MethodPost, Path: "/api/v1/posts/:id/comments", Handler: auth(a.createComment)}, + {Method: http.MethodDelete, Path: "/api/v1/comments/:id", Handler: auth(a.deleteOwnComment)}, + {Method: http.MethodPost, Path: "/api/v1/im/conversations/direct", Handler: auth(a.directConversation)}, + {Method: http.MethodGet, Path: "/api/v1/im/conversations", Handler: auth(a.conversations)}, + {Method: http.MethodGet, Path: "/api/v1/im/conversations/:id/messages", Handler: auth(a.messages)}, + {Method: http.MethodPost, Path: "/api/v1/im/conversations/:id/messages", Handler: auth(a.sendMessageHTTP)}, + {Method: http.MethodPost, Path: "/api/v1/im/messages/:id/recall", Handler: auth(a.recallMessage)}, + {Method: http.MethodPatch, Path: "/api/v1/im/conversations/:id/settings", Handler: auth(a.conversationSettings)}, + {Method: http.MethodGet, Path: "/api/v1/membership/status", Handler: auth(a.membershipStatus)}, + {Method: http.MethodPost, Path: "/api/v1/orders", Handler: auth(a.createOrder)}, + {Method: http.MethodPost, Path: "/api/v1/orders/:id/pay", Handler: auth(a.payOrder)}, + {Method: http.MethodGet, Path: "/api/v1/orders/:id", Handler: auth(a.orderStatus)}, + {Method: http.MethodPost, Path: "/api/v1/orders/:id/close", Handler: auth(a.closeOwnOrder)}, + {Method: http.MethodPost, Path: "/api/v1/orders/:id/refund", Handler: auth(a.requestOrderRefund)}, + {Method: http.MethodGet, Path: "/api/v1/me/orders", Handler: auth(a.myOrders)}, + {Method: http.MethodGet, Path: "/api/v1/notifications", Handler: auth(a.notifications)}, + {Method: http.MethodPost, Path: "/api/v1/notifications/read-all", Handler: auth(a.readAllNotifications)}, + {Method: http.MethodPost, Path: "/api/v1/notifications/:id/read", Handler: auth(a.readNotification)}, + {Method: http.MethodPost, Path: "/api/v1/reports", Handler: auth(a.createReport)}, + {Method: http.MethodGet, Path: "/api/v1/me/reports", Handler: auth(a.myReports)}, + {Method: http.MethodGet, Path: "/ws", Handler: a.websocket}, + } +} + +func (a *App) adminRoutes() []rest.Route { + auth := func(next http.HandlerFunc) http.HandlerFunc { return a.requireAuth("admin", next) } + permit := func(permission string, next http.HandlerFunc) http.HandlerFunc { + return a.requireAdminPermission(permission, next) + } + return []rest.Route{ + {Method: http.MethodPut, Path: "/admin/v1/me/password", Handler: auth(a.adminChangePassword)}, + {Method: http.MethodGet, Path: "/admin/v1/auth/codes", Handler: auth(a.adminCodes)}, + {Method: http.MethodGet, Path: "/admin/v1/user/info", Handler: auth(a.adminInfo)}, + {Method: http.MethodGet, Path: "/admin/v1/dashboard/overview", Handler: permit("dashboard:view", a.dashboard)}, + {Method: http.MethodGet, Path: "/admin/v1/users", Handler: permit("users:view", a.adminUsers)}, + {Method: http.MethodPost, Path: "/admin/v1/users", Handler: permit("users:create", a.adminCreateUser)}, + {Method: http.MethodGet, Path: "/admin/v1/users/:id", Handler: permit("users:view", a.adminUserDetail)}, + {Method: http.MethodPut, Path: "/admin/v1/users/:id/profile", Handler: permit("users:manage", a.adminUpdateUserProfile)}, + {Method: http.MethodPut, Path: "/admin/v1/users/:id/verification", Handler: permit("verification:manage", a.adminUpdateVerification)}, + {Method: http.MethodPut, Path: "/admin/v1/users/:id/membership", Handler: permit("users:manage", a.adminUpdateMembership)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/password-reset", Handler: permit("users:security", a.adminResetUserPassword)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/force-logout", Handler: permit("users:security", a.adminForceLogout)}, + {Method: http.MethodGet, Path: "/admin/v1/users/:id/sanctions", Handler: permit("violations:manage", a.adminUserSanctions)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/sanctions", Handler: permit("violations:manage", a.adminUserSanctions)}, + {Method: http.MethodPost, Path: "/admin/v1/sanctions/:id/revoke", Handler: permit("violations:manage", a.adminRevokeSanction)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/freeze", Handler: permit("violations:manage", a.adminUserStatus)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/unfreeze", Handler: permit("violations:manage", a.adminUserStatus)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/ban", Handler: permit("violations:manage", a.adminUserStatus)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/unban", Handler: permit("violations:manage", a.adminUserStatus)}, + {Method: http.MethodGet, Path: "/admin/v1/posts", Handler: permit("content:view", a.adminPosts)}, + {Method: http.MethodGet, Path: "/admin/v1/posts/:id", Handler: permit("content:view", a.adminPostDetail)}, + {Method: http.MethodDelete, Path: "/admin/v1/posts/:id", Handler: permit("content:manage", a.adminDeletePost)}, + {Method: http.MethodGet, Path: "/admin/v1/reports", Handler: permit("reports:handle", a.adminReports)}, + {Method: http.MethodPost, Path: "/admin/v1/reports/:id/handle", Handler: permit("reports:handle", a.adminHandleReport)}, + {Method: http.MethodGet, Path: "/admin/v1/risk/users", Handler: permit("risk:view", a.adminRiskUsers)}, + {Method: http.MethodGet, Path: "/admin/v1/risk/events", Handler: permit("risk:view", a.adminRiskEvents)}, + {Method: http.MethodGet, Path: "/admin/v1/membership/plans", Handler: permit("membership:manage", a.adminPlans)}, + {Method: http.MethodPost, Path: "/admin/v1/membership/plans", Handler: permit("membership:manage", a.adminCreatePlan)}, + {Method: http.MethodPatch, Path: "/admin/v1/membership/plans/:id", Handler: permit("membership:manage", a.adminUpdatePlan)}, + {Method: http.MethodPut, Path: "/admin/v1/membership/plans/:id", Handler: permit("membership:manage", a.adminUpdatePlan)}, + {Method: http.MethodDelete, Path: "/admin/v1/membership/plans/:id", Handler: permit("membership:manage", a.adminDeletePlan)}, + {Method: http.MethodGet, Path: "/admin/v1/orders", Handler: permit("orders:view", a.adminOrders)}, + {Method: http.MethodPut, Path: "/admin/v1/orders/:id", Handler: permit("orders:manage", a.adminUpdateOrder)}, + {Method: http.MethodDelete, Path: "/admin/v1/orders/:id", Handler: permit("orders:manage", a.adminDeleteOrder)}, + {Method: http.MethodPost, Path: "/admin/v1/orders/:id/pay", Handler: permit("orders:manage", a.adminMarkOrderPaid)}, + {Method: http.MethodPost, Path: "/admin/v1/orders/:id/close", Handler: permit("orders:manage", a.adminCloseOrder)}, + {Method: http.MethodPost, Path: "/admin/v1/orders/:id/refund", Handler: permit("orders:manage", a.adminRefund)}, + {Method: http.MethodGet, Path: "/admin/v1/messages", Handler: permit("messages:view", a.adminMessages)}, + {Method: http.MethodPost, Path: "/admin/v1/messages/:id/moderate", Handler: permit("messages:manage", a.adminModerateMessage)}, + {Method: http.MethodGet, Path: "/admin/v1/client-feedback", Handler: permit("users:view", a.adminFeedback)}, + {Method: http.MethodPut, Path: "/admin/v1/client-feedback/:id", Handler: permit("users:manage", a.adminFeedback)}, + {Method: http.MethodGet, Path: "/admin/v1/account-closures", Handler: permit("users:view", a.adminAccountClosures)}, + {Method: http.MethodPut, Path: "/admin/v1/account-closures/:id", Handler: permit("users:manage", a.adminAccountClosures)}, + {Method: http.MethodGet, Path: "/admin/v1/app-versions", Handler: permit("system:manage", a.adminAppVersions)}, + {Method: http.MethodPost, Path: "/admin/v1/app-versions", Handler: permit("system:manage", a.adminAppVersions)}, + {Method: http.MethodPut, Path: "/admin/v1/app-versions/:id", Handler: permit("system:manage", a.adminAppVersions)}, + {Method: http.MethodDelete, Path: "/admin/v1/app-versions/:id", Handler: permit("system:manage", a.adminAppVersions)}, + {Method: http.MethodGet, Path: "/admin/v1/system/configs", Handler: permit("system:manage", a.adminConfigs)}, + {Method: http.MethodPatch, Path: "/admin/v1/system/configs/:key", Handler: permit("system:manage", a.adminUpdateConfig)}, + {Method: http.MethodPut, Path: "/admin/v1/system/configs/:key", Handler: permit("system:manage", a.adminUpdateConfig)}, + {Method: http.MethodGet, Path: "/admin/v1/integrations/:group", Handler: permit("system:manage", a.adminIntegration)}, + {Method: http.MethodPut, Path: "/admin/v1/integrations/:group", Handler: permit("system:manage", a.adminUpdateIntegration)}, + {Method: http.MethodPost, Path: "/admin/v1/integrations/:group/test", Handler: permit("system:manage", a.adminTestIntegration)}, + {Method: http.MethodGet, Path: "/admin/v1/audit-logs", Handler: permit("system:manage", a.adminAuditLogs)}, + {Method: http.MethodGet, Path: "/admin/v1/admin-users", Handler: permit("system:manage", a.adminAccounts)}, + {Method: http.MethodPost, Path: "/admin/v1/admin-users", Handler: permit("system:manage", a.adminCreateAccount)}, + {Method: http.MethodPut, Path: "/admin/v1/admin-users/:id", Handler: permit("system:manage", a.adminUpdateAccount)}, + {Method: http.MethodGet, Path: "/admin/v1/admin-roles", Handler: permit("system:manage", a.adminRoles)}, + {Method: http.MethodPost, Path: "/admin/v1/admin-roles", Handler: permit("system:manage", a.adminCreateRole)}, + {Method: http.MethodPut, Path: "/admin/v1/admin-roles/:id", Handler: permit("system:manage", a.adminUpdateRole)}, + {Method: http.MethodDelete, Path: "/admin/v1/admin-roles/:id", Handler: permit("system:manage", a.adminDeleteRole)}, + {Method: http.MethodGet, Path: "/admin/v1/admin-permissions", Handler: permit("system:manage", a.adminPermissions)}, + } +} + +func (a *App) health(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), time.Second) + defer cancel() + if err := a.db.PingContext(ctx); err != nil { + fail(w, http.StatusServiceUnavailable, 50001, "database unavailable") + return + } + reply(w, map[string]any{"status": "ok", "time": time.Now()}) +} + +func reply(w http.ResponseWriter, data any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(apiResponse{Code: 0, Message: "OK", Data: data}) +} + +func fail(w http.ResponseWriter, status, code int, message string) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(apiResponse{Code: code, Message: message, Data: nil}) +} + +func decode(r *http.Request, out any) error { + decoder := json.NewDecoder(io.LimitReader(r.Body, 2<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(out); err != nil { + return fmt.Errorf("invalid request: %w", err) + } + return nil +} + +func pagination(r *http.Request) (int, int, int) { + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + size, _ := strconv.Atoi(r.URL.Query().Get("size")) + if page < 1 { + page = 1 + } + if size < 1 { + size = 20 + } + if size > 100 { + size = 100 + } + return page, size, (page - 1) * size +} + +func pathID(r *http.Request) (int64, error) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + for i := len(parts) - 1; i >= 0; i-- { + if id, err := strconv.ParseInt(parts[i], 10, 64); err == nil { + return id, nil + } + } + return 0, errors.New("invalid id") +} + +func env(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func boolEnv(key string, fallback bool) bool { + value := strings.TrimSpace(strings.ToLower(os.Getenv(key))) + if value == "" { + return fallback + } + return value == "1" || value == "true" || value == "yes" || value == "on" +} + +func csvEnv(key, fallback string) []string { + value := env(key, fallback) + items := make([]string, 0) + seen := map[string]bool{} + for _, item := range strings.Split(value, ",") { + item = strings.TrimRight(strings.TrimSpace(item), "/") + if item != "" && !seen[item] { + seen[item] = true + items = append(items, item) + } + } + return items +} + +func validateConfig(config Config) error { + if config.Port < 1 || config.Port > 65535 { + return fmt.Errorf("IM_PORT 无效") + } + if config.Environment != "production" { + return nil + } + if config.SeedDemo { + return fmt.Errorf("生产环境禁止启用 IM_SEED_DEMO") + } + if len(config.JWTSecret) < 32 || config.JWTSecret == "local-development-secret-change-me" { + return fmt.Errorf("生产环境必须配置至少 32 字节的 IM_JWT_SECRET") + } + if len(config.ConfigEncryptionKey) < 32 { + return fmt.Errorf("生产环境必须配置至少 32 字节的 IM_CONFIG_ENCRYPTION_KEY") + } + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(config.DSN)), "root:") { + return fmt.Errorf("生产环境禁止使用 root 数据库账号") + } + if len(config.AllowedOrigins) == 0 { + return fmt.Errorf("生产环境必须配置 IM_ALLOWED_ORIGINS") + } + for _, origin := range config.AllowedOrigins { + if origin == "*" { + return fmt.Errorf("生产环境禁止使用通配 CORS 来源") + } + if !strings.HasPrefix(origin, "https://") { + return fmt.Errorf("生产环境来源必须使用 HTTPS: %s", origin) + } + } + return nil +} + +func (a *App) originAllowed(origin string) bool { + origin = strings.TrimRight(strings.TrimSpace(origin), "/") + if origin == "" { + return true + } + for _, allowed := range a.config.AllowedOrigins { + if origin == allowed { + return true + } + } + return false +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/app_oauth.go b/im/.deploy/application-20260831-120612/backend/internal/app/app_oauth.go new file mode 100644 index 0000000..03f7caa --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/app_oauth.go @@ -0,0 +1,264 @@ +package app + +import ( + "context" + "crypto/subtle" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +// This URL is registered in the Android/iOS manifest. Never accept a callback +// supplied by an unauthenticated caller (including arbitrary custom schemes). +const appOAuthCallbackURL = "xingyuim://oauth/callback" + +func oauthClientPlatform(raw string) (string, error) { + switch raw { + case "", "h5": + return "h5", nil + case "app": + return "app", nil + default: + return "", errors.New("不支持的登录客户端") + } +} + +func userOAuthEnabledKey(platform, provider string) string { + if platform == "app" { + return "oauth.app." + provider + ".enabled" + } + return "oauth.user." + provider + ".enabled" +} + +func (a *App) appOAuthFrontendURL(ctx context.Context) (string, error) { + raw := a.configPlain(ctx, "oauth.app.frontend_callback_url", "") + if raw != appOAuthCallbackURL { + return "", errors.New("App 回调地址必须为 " + appOAuthCallbackURL) + } + return raw, nil +} + +func validateNativeOAuthConfig(code string, value func(string) string) error { + prefix := "oauth.app." + code + "." + switch code { + case "wechat", "qq": + id := value(prefix + "client_id") + if id == "" || len(id) > 128 { + return fmt.Errorf("App %s AppID 未配置或过长", adminOAuthProviderNames[code]) + } + if code == "wechat" && value(prefix+"client_secret") == "" { + return errors.New("App 微信 AppSecret 未配置") + } + case "google": + ids := strings.Split(value(prefix+"client_ids"), ",") + for _, id := range ids { + id = strings.TrimSpace(id) + if !strings.HasSuffix(id, ".apps.googleusercontent.com") || strings.ContainsAny(id, " \r\n\t/") { + return errors.New("App Google Client ID 列表无效,请用英文逗号分隔 Android/iOS 客户端 ID") + } + } + default: + return errors.New("不支持的 App 原生登录渠道") + } + return nil +} + +func (a *App) appOAuthProvider(ctx context.Context, code string) (adminOAuthProvider, error) { + if code == "github" { + if _, err := a.appOAuthFrontendURL(ctx); err != nil { + return adminOAuthProvider{}, err + } + return a.adminOAuthProvider(ctx, code) + } + value := func(key string) string { return strings.TrimSpace(a.configPlain(ctx, key, "")) } + if err := validateNativeOAuthConfig(code, value); err != nil { + return adminOAuthProvider{}, err + } + p := adminOAuthProvider{Code: code, Name: adminOAuthProviderNames[code], ClientID: value("oauth.app." + code + ".client_id")} + switch code { + case "wechat": + p.ClientSecret = value("oauth.app.wechat.client_secret") + p.TokenURL = "https://api.weixin.qq.com/sns/oauth2/access_token" + p.UserInfoURL = "https://api.weixin.qq.com/sns/userinfo" + case "qq": + p.OpenIDURL = "https://graph.qq.com/oauth2.0/me" + p.UserInfoURL = "https://graph.qq.com/user/get_user_info" + case "google": + p.UserInfoURL = "https://openidconnect.googleapis.com/v1/userinfo" + } + return p, nil +} + +func (a *App) enabledAppOAuthProviders(ctx context.Context) ([]adminOAuthProvider, error) { + providers := []adminOAuthProvider{} + for _, code := range []string{"wechat", "qq", "github", "google"} { + if !a.configBool(ctx, userOAuthEnabledKey("app", code), false) { + continue + } + p, err := a.appOAuthProvider(ctx, code) + if err != nil { + return nil, err + } + providers = append(providers, p) + } + return providers, nil +} + +func (a *App) issueUserOAuthCode(ctx context.Context, provider, platform, scope string, proofHash []byte, identity adminOAuthIdentity) (string, error) { + var userID sql.NullInt64 + err := a.db.QueryRowContext(ctx, `SELECT user_id FROM user_oauth_identities WHERE provider=? AND identity_scope=? AND subject=?`, provider, scope, identity.Subject).Scan(&userID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return "", err + } + code := randomToken() + _, err = a.db.ExecContext(ctx, `INSERT INTO user_oauth_login_codes(code_hash,provider,subject,email,display_name,avatar_url,user_id,expires_at,client_platform,identity_scope,app_proof_hash) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, oauthHash(code), provider, identity.Subject, identity.Email, identity.DisplayName, identity.AvatarURL, userID, time.Now().Add(adminOAuthCodeTTL), platform, scope, proofHash) + return code, err +} + +func validAppOAuthProof(expected []byte, proof string) bool { + return len(expected) == 0 || (len(proof) >= 32 && subtle.ConstantTimeCompare(expected, oauthHash(proof)) == 1) +} + +func (a *App) validateUserOAuthCode(ctx context.Context, code userOAuthLoginCode, proof string) error { + if !validAppOAuthProof(code.AppProofHash, proof) { + return errors.New("授权结果与发起登录的 App 不匹配,请重新登录") + } + if !a.configBool(ctx, userOAuthEnabledKey(code.Platform, code.Provider), false) { + return errors.New("该客户端登录方式已停用") + } + return nil +} + +func (a *App) userOAuthNative(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + Code string `json:"code"` + AccessToken string `json:"accessToken"` + } + if decode(r, &req) != nil || len(req.Code) > 4096 || len(req.AccessToken) > 8192 { + fail(w, 400, 20001, "原生授权参数无效") + return + } + if req.Provider != "wechat" && req.Provider != "qq" && req.Provider != "google" { + fail(w, 400, 20001, "不支持的 App 原生登录渠道") + return + } + if !a.rateLimit(w, r, "user_oauth_native", clientIP(r), 20, 10*time.Minute) { + return + } + if !a.configBool(r.Context(), userOAuthEnabledKey("app", req.Provider), false) { + fail(w, 400, 20001, "该 App 登录方式未启用") + return + } + p, err := a.appOAuthProvider(r.Context(), req.Provider) + if err != nil { + fail(w, 400, 20001, "该 App 登录方式配置不完整") + return + } + var identity adminOAuthIdentity + if req.Provider == "wechat" { + if strings.TrimSpace(req.Code) == "" { + fail(w, 400, 20001, "微信授权码不能为空") + return + } + identity, err = a.fetchAdminOAuthIdentity(r.Context(), p, req.Code, "") + } else { + identity, err = a.fetchNativeTokenIdentity(r.Context(), p, req.AccessToken) + } + if err != nil { + fail(w, 400, 20001, "App 授权验证失败,请检查平台凭证、AppID 和 SDK 配置后重试") + return + } + // OpenID from a mobile application must not collide with a website OpenID. + scope := "" + if p.Code == "wechat" || p.Code == "qq" { + scope = "app:" + p.ClientID + } + code, err := a.issueUserOAuthCode(r.Context(), p.Code, "app", scope, nil, identity) + if err != nil { + fail(w, 500, 50001, "创建 App 登录凭证失败") + return + } + a.cleanupUserOAuthRecords(r.Context()) + w.Header().Set("Cache-Control", "no-store") + reply(w, map[string]string{"oauthCode": code}) +} + +func googleTokenAudienceAllowed(audience, issuedTo, allowed string) bool { + // Both the recipient and the party to which the token was issued must be + // ours. A valid Google token minted for another application is insufficient. + ids := strings.Split(allowed, ",") + for i := range ids { + ids[i] = strings.TrimSpace(ids[i]) + } + return audience != "" && issuedTo != "" && containsString(ids, audience) && containsString(ids, issuedTo) +} + +func (a *App) fetchNativeTokenIdentity(ctx context.Context, p adminOAuthProvider, token string) (adminOAuthIdentity, error) { + if strings.TrimSpace(token) == "" { + return adminOAuthIdentity{}, errors.New("missing access token") + } + if p.Code == "qq" { + var info struct { + ClientID string `json:"client_id"` + OpenID string `json:"openid"` + Error int `json:"error"` + } + if err := a.oauthGetJSON(ctx, p.OpenIDURL, url.Values{"access_token": {token}, "fmt": {"json"}}, &info); err != nil { + return adminOAuthIdentity{}, err + } + if info.Error != 0 || info.OpenID == "" || info.ClientID != p.ClientID { + return adminOAuthIdentity{}, errors.New("QQ token audience mismatch") + } + var profile struct { + Ret int `json:"ret"` + Name string `json:"nickname"` + Picture string `json:"figureurl_qq_2"` + } + if err := a.oauthGetJSON(ctx, p.UserInfoURL, url.Values{"access_token": {token}, "oauth_consumer_key": {p.ClientID}, "openid": {info.OpenID}, "format": {"json"}}, &profile); err != nil { + return adminOAuthIdentity{}, err + } + if profile.Ret != 0 { + return adminOAuthIdentity{}, errors.New("QQ user info rejected") + } + return adminOAuthIdentity{Subject: info.OpenID, DisplayName: profile.Name, AvatarURL: profile.Picture}, nil + } + if p.Code != "google" { + return adminOAuthIdentity{}, errors.New("unsupported token provider") + } + var info struct { + Audience string `json:"audience"` + IssuedTo string `json:"issued_to"` + UserID string `json:"user_id"` + ExpiresIn json.Number `json:"expires_in"` + } + if err := a.oauthGetJSON(ctx, "https://www.googleapis.com/oauth2/v1/tokeninfo", url.Values{"access_token": {token}}, &info); err != nil { + return adminOAuthIdentity{}, err + } + expires, _ := info.ExpiresIn.Int64() + if expires <= 0 || info.UserID == "" || !googleTokenAudienceAllowed(info.Audience, info.IssuedTo, a.configPlain(ctx, "oauth.app.google.client_ids", "")) { + return adminOAuthIdentity{}, errors.New("Google token audience or expiry invalid") + } + var profile struct { + Subject string `json:"sub"` + Name string `json:"name"` + Picture string `json:"picture"` + Email string `json:"email"` + Verified bool `json:"email_verified"` + } + if err := a.oauthBearerJSON(ctx, p.UserInfoURL, token, &profile); err != nil { + return adminOAuthIdentity{}, err + } + if profile.Subject != info.UserID { + return adminOAuthIdentity{}, errors.New("Google token subject mismatch") + } + if !profile.Verified { + profile.Email = "" + } + return adminOAuthIdentity{Subject: profile.Subject, DisplayName: profile.Name, AvatarURL: profile.Picture, Email: profile.Email}, nil +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/app_oauth_test.go b/im/.deploy/application-20260831-120612/backend/internal/app/app_oauth_test.go new file mode 100644 index 0000000..e68bcb3 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/app_oauth_test.go @@ -0,0 +1,361 @@ +package app + +import ( + "bytes" + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// Small in-memory SQL adapter keeps these HTTP policy tests independent of a +// developer's database and credentials. Unexpected queries fail the test. +type oauthTestDB struct { + values map[string]string + code userOAuthLoginCode + codeUsed bool + stateHash []byte + stateProvider, stateVerifier, statePlatform string + stateProof []byte + stateUsed bool +} +type oauthTestConnector struct{ db *oauthTestDB } + +func (c oauthTestConnector) Connect(context.Context) (driver.Conn, error) { return c.db, nil } +func (c oauthTestConnector) Driver() driver.Driver { return oauthTestDriver{} } + +type oauthTestDriver struct{} + +func (oauthTestDriver) Open(string) (driver.Conn, error) { return nil, fmt.Errorf("use connector") } +func (*oauthTestDB) Prepare(string) (driver.Stmt, error) { + return nil, fmt.Errorf("unexpected prepare") +} +func (*oauthTestDB) Close() error { return nil } +func (*oauthTestDB) Begin() (driver.Tx, error) { return nil, fmt.Errorf("unexpected transaction") } + +type oauthTestRows struct { + columns []string + values [][]driver.Value +} + +func (r *oauthTestRows) Columns() []string { return r.columns } +func (*oauthTestRows) Close() error { return nil } +func (r *oauthTestRows) Next(dest []driver.Value) error { + if len(r.values) == 0 { + return io.EOF + } + copy(dest, r.values[0]) + r.values = r.values[1:] + return nil +} +func oauthRow(values ...driver.Value) driver.Rows { + columns := make([]string, len(values)) + for i := range columns { + columns[i] = fmt.Sprintf("c%d", i) + } + return &oauthTestRows{columns: columns, values: [][]driver.Value{values}} +} +func (db *oauthTestDB) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { + switch { + case strings.Contains(query, "FROM system_configs"): + value, ok := db.values[args[0].Value.(string)] + if !ok { + return &oauthTestRows{columns: []string{"config_value", "value_type"}}, nil + } + return oauthRow(value, "string"), nil + case strings.Contains(query, "SELECT hits FROM api_rate_limits"): + return oauthRow(int64(1)), nil + case strings.Contains(query, "SELECT provider,code_verifier,client_platform,app_proof_hash FROM user_oauth_states"): + if db.stateUsed || !bytes.Equal(db.stateHash, args[0].Value.([]byte)) { + return &oauthTestRows{columns: []string{"provider", "verifier", "platform", "proof"}}, nil + } + return oauthRow(db.stateProvider, db.stateVerifier, db.statePlatform, db.stateProof), nil + case strings.Contains(query, "SELECT user_id FROM user_oauth_identities"): + return &oauthTestRows{columns: []string{"user_id"}}, nil + case strings.Contains(query, "FROM user_oauth_login_codes"): + c := db.code + return oauthRow(c.Provider, c.Subject, c.Email, c.DisplayName, c.AvatarURL, nil, c.Platform, c.IdentityScope, c.AppProofHash), nil + case strings.Contains(query, "SELECT p.nickname,u.status"): + return oauthRow("tester", int64(1)), nil + default: + return nil, fmt.Errorf("unexpected query: %s", query) + } +} +func (db *oauthTestDB) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + switch { + case strings.Contains(query, "INSERT INTO user_oauth_states"): + db.stateHash = args[0].Value.([]byte) + db.stateProvider, db.stateVerifier, db.statePlatform = args[1].Value.(string), args[2].Value.(string), args[4].Value.(string) + db.stateProof, _ = args[5].Value.([]byte) + return driver.RowsAffected(1), nil + case strings.Contains(query, "UPDATE user_oauth_states"): + if db.stateUsed || !bytes.Equal(db.stateHash, args[0].Value.([]byte)) { + return driver.RowsAffected(0), nil + } + db.stateUsed = true + return driver.RowsAffected(1), nil + case strings.Contains(query, "INSERT INTO user_oauth_login_codes"): + db.code.Provider, db.code.Subject, db.code.Platform, db.code.IdentityScope = args[1].Value.(string), args[2].Value.(string), args[8].Value.(string), args[9].Value.(string) + db.code.AppProofHash, _ = args[10].Value.([]byte) + return driver.RowsAffected(1), nil + case strings.HasPrefix(query, "DELETE FROM user_oauth_"): + return driver.RowsAffected(0), nil + case strings.Contains(query, "api_rate_limits"), strings.Contains(query, "UPDATE user_oauth_identities SET last_login_at"): + return driver.RowsAffected(1), nil + case strings.Contains(query, "UPDATE user_oauth_login_codes SET used_at"): + if db.codeUsed { + return driver.RowsAffected(0), nil + } + db.codeUsed = true + return driver.RowsAffected(1), nil + default: + return nil, fmt.Errorf("unexpected exec: %s", query) + } +} +func oauthTestApp(t *testing.T, values map[string]string) (*App, *oauthTestDB) { + t.Helper() + store := &oauthTestDB{values: values} + db := sql.OpenDB(oauthTestConnector{store}) + t.Cleanup(func() { _ = db.Close() }) + return &App{db: db, config: Config{Environment: "development"}}, store +} +func oauthPost(handler http.HandlerFunc, body string) *httptest.ResponseRecorder { + r := httptest.NewRequest("POST", "/api/v1/auth/oauth/test", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler(w, r) + return w +} + +func TestAppOAuthProviderSwitchesAndConfiguration(t *testing.T) { + app, store := oauthTestApp(t, map[string]string{ + "oauth.user.qq.enabled": "true", "oauth.app.qq.enabled": "false", + "oauth.app.qq.client_id": "mobile-id", "oauth.app.wechat.enabled": "true", + }) + list := func(platform string) string { + w := httptest.NewRecorder() + app.userOAuthProviders(w, httptest.NewRequest("GET", "/?platform="+platform, nil)) + return w.Body.String() + } + if strings.Contains(list("app"), `"code":"qq"`) { + t.Fatal("H5 switch must not enable App") + } + store.values["oauth.app.qq.enabled"] = "true" + body := list("app") + if !strings.Contains(body, `"code":"qq"`) || strings.Contains(body, `"code":"wechat"`) { + t.Fatalf("only complete, enabled channels should be returned: %s", body) + } + if strings.Contains(body, "mobile-id") { + t.Fatal("provider discovery must not return credentials") + } + w := httptest.NewRecorder() + app.userOAuthProviders(w, httptest.NewRequest("GET", "/?platform=unknown", nil)) + if w.Code != 400 { + t.Fatal("unknown platform must be rejected") + } +} + +func TestDisabledAppOAuthBlocksEveryLoginEntry(t *testing.T) { + app, store := oauthTestApp(t, map[string]string{"oauth.user.qq.enabled": "true", "oauth.app.qq.enabled": "false"}) + store.code = userOAuthLoginCode{Provider: "qq", Platform: "app", Subject: "openid"} + for name, handler := range map[string]http.HandlerFunc{"native": app.userOAuthNative, "exchange": app.userOAuthExchange, "link": app.userOAuthLink} { + bodies := map[string]string{ + "native": `{"provider":"qq","accessToken":"token"}`, + "exchange": `{"code":"ticket"}`, + "link": `{"code":"ticket","phone":"13800138000","smsCode":"123456"}`, + } + w := oauthPost(handler, bodies[name]) + if w.Code != 400 || !(strings.Contains(w.Body.String(), "停用") || strings.Contains(w.Body.String(), "未启用")) { + t.Fatalf("%s accepted a disabled App provider: %d %s", name, w.Code, w.Body.String()) + } + } +} + +func TestAppOAuthCallbackProofAndCodeReplay(t *testing.T) { + proof := strings.Repeat("p", 43) + app, store := oauthTestApp(t, map[string]string{"oauth.app.github.enabled": "true"}) + store.code = userOAuthLoginCode{Provider: "github", Platform: "app", AppProofHash: oauthHash(proof)} + for _, supplied := range []string{"", strings.Repeat("x", 43)} { + w := oauthPost(app.userOAuthExchange, `{"code":"intercepted-ticket","appProof":"`+supplied+`"}`) + if w.Code != 400 { + t.Fatal("intercepted callback must not authorize a different App instance") + } + } + w := oauthPost(app.userOAuthExchange, `{"code":"ticket","appProof":"`+proof+`"}`) + if w.Code != 200 || !strings.Contains(w.Body.String(), `"requiresLink":true`) { + t.Fatalf("correct proof rejected: %s", w.Body.String()) + } + if _, _, err := app.consumeUserOAuthCode(context.Background(), "ticket", 42); err != nil { + t.Fatal(err) + } + if _, _, err := app.consumeUserOAuthCode(context.Background(), "ticket", 42); err == nil { + t.Fatal("login ticket reused") + } +} + +func TestAppOAuthConfigurationAndRouting(t *testing.T) { + values := map[string]string{ + "oauth.admin.frontend_callback_url": "http://localhost:5560/auth/social-callback", + "oauth.user.frontend_callback_url": "http://localhost:5174/#/pages/auth/oauth-callback", + "oauth.app.qq.enabled": "true", "oauth.app.qq.client_id": "mobile-id", + } + app, _ := oauthTestApp(t, map[string]string{}) + app.config.Environment = "production" + if err := app.validateAdminOAuthConfigValues(context.Background(), values, nil); err != nil { + t.Fatalf("native-only config should not require QQ website credentials: %v", err) + } + delete(values, "oauth.app.qq.client_id") + if err := app.validateAdminOAuthConfigValues(context.Background(), values, nil); err == nil { + t.Fatal("enabled incomplete native config accepted") + } + if validAppOAuthProof(oauthHash("required"), "") { + t.Fatal("missing callback proof accepted") + } + if googleTokenAudienceAllowed("other", "other", "ours") || googleTokenAudienceAllowed("ours", "other", "ours") { + t.Fatal("foreign Google application accepted") + } + if !googleTokenAudienceAllowed("android", "ios", "android, ios") { + t.Fatal("allowed app clients rejected") + } + app, _ = oauthTestApp(t, map[string]string{"oauth.app.frontend_callback_url": "javascript:alert(1)"}) + if _, err := app.appOAuthFrontendURL(context.Background()); err == nil { + t.Fatal("unsafe callback accepted") + } +} + +type oauthRoundTripper func(*http.Request) (*http.Response, error) + +func (f oauthRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } +func mockOAuthHTTP(t *testing.T, responder func(*http.Request) string) { + t.Helper() + original := http.DefaultTransport + http.DefaultTransport = oauthRoundTripper(func(r *http.Request) (*http.Response, error) { + if r.URL.Scheme != "https" { + t.Fatal("credentials sent over insecure transport") + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(responder(r)))}, nil + }) + t.Cleanup(func() { http.DefaultTransport = original }) +} + +func TestNativeQQValidatesTokenOwner(t *testing.T) { + app := &App{} + clientID := "foreign-app" + profileCalls := 0 + mockOAuthHTTP(t, func(r *http.Request) string { + if r.URL.Path == "/oauth2.0/me" { + return `{"openid":"verified-user","client_id":"` + clientID + `"}` + } + profileCalls++ + if r.URL.Query().Get("openid") != "verified-user" { + t.Fatal("unverified OpenID used") + } + return `{"ret":0,"nickname":"tester","figureurl_qq_2":"https://example.com/avatar.png"}` + }) + p := adminOAuthProvider{Code: "qq", ClientID: "our-app", OpenIDURL: "https://graph.qq.com/oauth2.0/me", UserInfoURL: "https://graph.qq.com/user/get_user_info"} + if _, err := app.fetchNativeTokenIdentity(context.Background(), p, "token"); err == nil || profileCalls != 0 { + t.Fatal("foreign QQ token was accepted") + } + clientID = "our-app" + identity, err := app.fetchNativeTokenIdentity(context.Background(), p, "token") + if err != nil || identity.Subject != "verified-user" { + t.Fatalf("valid QQ token rejected: %v", err) + } +} + +func TestNativeGoogleChecksAudienceExpiryAndSubject(t *testing.T) { + app, _ := oauthTestApp(t, map[string]string{"oauth.app.google.client_ids": "our-app"}) + audience, expires, subject := "other-app", "3600", "verified-user" + mockOAuthHTTP(t, func(r *http.Request) string { + if r.URL.Path == "/oauth2/v1/tokeninfo" { + return `{"audience":"` + audience + `","issued_to":"` + audience + `","user_id":"verified-user","expires_in":` + expires + `}` + } + if r.Header.Get("Authorization") != "Bearer token" { + t.Fatal("missing bearer token") + } + return `{"sub":"` + subject + `","name":"tester","email":"unverified@example.com","email_verified":false}` + }) + p := adminOAuthProvider{Code: "google", UserInfoURL: "https://openidconnect.googleapis.com/v1/userinfo"} + checkRejected := func() { + t.Helper() + if _, err := app.fetchNativeTokenIdentity(context.Background(), p, "token"); err == nil { + t.Fatal("invalid Google token accepted") + } + } + checkRejected() + audience, expires = "our-app", "0" + checkRejected() + expires, subject = "3600", "another-user" + checkRejected() + subject = "verified-user" + identity, err := app.fetchNativeTokenIdentity(context.Background(), p, "token") + if err != nil || identity.Subject != subject || identity.Email != "" { + t.Fatalf("verified Google token failed: %#v %v", identity, err) + } +} + +func TestAppGitHubAuthorizationRoundTrip(t *testing.T) { + app, store := oauthTestApp(t, map[string]string{ + "oauth.app.github.enabled": "true", + "oauth.app.frontend_callback_url": appOAuthCallbackURL, + "oauth.github.client_id": "our-github-app", + "oauth.github.client_secret": "server-secret", + "oauth.github.authorization_url": "https://github.com/login/oauth/authorize", + "oauth.github.token_url": "https://github.com/login/oauth/access_token", + "oauth.github.userinfo_url": "https://api.github.com/user", + "oauth.github.scope": "read:user", + "oauth.github.redirect_uri": "https://api.example.com/api/v1/auth/oauth/callback", + }) + mockOAuthHTTP(t, func(r *http.Request) string { + if r.URL.Path == "/login/oauth/access_token" { + _ = r.ParseForm() + if r.Form.Get("code_verifier") != store.stateVerifier || r.Form.Get("code") != "provider-code" { + t.Fatal("missing PKCE verifier or code") + } + return `{"access_token":"provider-token"}` + } + return `{"id":123,"login":"tester"}` + }) + w := oauthPost(app.userOAuthStart, `{"provider":"github","platform":"app"}`) + var start struct { + Data map[string]string `json:"data"` + } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &start) != nil { + t.Fatalf("start failed: %s", w.Body.String()) + } + authURL, _ := url.Parse(start.Data["authorizationUrl"]) + if authURL.Query().Get("state") != start.Data["requestId"] || authURL.Query().Get("code_challenge") != pkceChallenge(store.stateVerifier) { + t.Fatal("authorization is not bound to the request") + } + if !bytes.Equal(store.stateProof, oauthHash(start.Data["appProof"])) { + t.Fatal("proof not hashed at rest") + } + callback := "/api/v1/auth/oauth/callback?state=" + url.QueryEscape(start.Data["requestId"]) + "&code=provider-code" + w = httptest.NewRecorder() + app.userOAuthCallback(w, httptest.NewRequest("GET", callback, nil)) + resultURL, err := url.Parse(w.Header().Get("Location")) + if w.Code != 302 || err != nil || resultURL.Scheme != "xingyuim" { + t.Fatalf("App callback failed: %d %s", w.Code, w.Body.String()) + } + if resultURL.Query().Get("requestId") != start.Data["requestId"] || resultURL.Query().Get("oauthCode") == "" { + t.Fatal("App callback missing correlation or ticket") + } + if strings.Contains(resultURL.String(), start.Data["appProof"]) { + t.Fatal("private proof leaked into browser redirect") + } + if store.code.Platform != "app" || !bytes.Equal(store.code.AppProofHash, store.stateProof) { + t.Fatal("login ticket lost its App binding") + } + w = httptest.NewRecorder() + app.userOAuthCallback(w, httptest.NewRequest("GET", callback, nil)) + if w.Code != 400 { + t.Fatal("authorization callback replay accepted") + } +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/auth.go b/im/.deploy/application-20260831-120612/backend/internal/app/auth.go new file mode 100644 index 0000000..7841fbd --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/auth.go @@ -0,0 +1,296 @@ +package app + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/base64" + "encoding/hex" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/bcrypt" +) + +type identity struct { + ID int64 + Role string + Name string + IssuedAt time.Time + Version int +} + +type identityKey struct{} + +type tokenClaims struct { + Role string `json:"role"` + Name string `json:"name"` + Version int `json:"ver,omitempty"` + jwt.RegisteredClaims +} + +func (a *App) token(id int64, role, name string, ttl time.Duration) (string, error) { + now := time.Now() + version := 0 + if role == "user" { + _ = a.db.QueryRow(`SELECT token_version FROM user_security_controls WHERE user_id=?`, id).Scan(&version) + } else if role == "admin" { + _ = a.db.QueryRow(`SELECT token_version FROM admin_users WHERE id=?`, id).Scan(&version) + } + claims := tokenClaims{ + Role: role, + Name: name, + Version: version, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: strconv.FormatInt(id, 10), + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(a.config.JWTSecret)) +} + +func (a *App) parseToken(raw string) (identity, error) { + parsed, err := jwt.ParseWithClaims(raw, &tokenClaims{}, func(t *jwt.Token) (any, error) { + if t.Method != jwt.SigningMethodHS256 { + return nil, fmt.Errorf("unexpected signing method") + } + return []byte(a.config.JWTSecret), nil + }) + if err != nil || !parsed.Valid { + return identity{}, fmt.Errorf("invalid token") + } + claims, ok := parsed.Claims.(*tokenClaims) + if !ok { + return identity{}, fmt.Errorf("invalid claims") + } + id, err := strconv.ParseInt(claims.Subject, 10, 64) + if err != nil { + return identity{}, fmt.Errorf("invalid subject") + } + issuedAt := time.Time{} + if claims.IssuedAt != nil { + issuedAt = claims.IssuedAt.Time + } + return identity{ID: id, Role: claims.Role, Name: claims.Name, IssuedAt: issuedAt, Version: claims.Version}, nil +} + +func (a *App) requireAuth(role string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + raw := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + if raw == "" { + fail(w, http.StatusUnauthorized, 10001, "请先登录") + return + } + who, err := a.parseToken(raw) + if err != nil || who.Role != role { + fail(w, http.StatusUnauthorized, 10001, "登录状态已失效") + return + } + if role == "user" { + if a.executeDueAccountClosure(r, who.ID) { + fail(w, http.StatusUnauthorized, 10001, "账号已按注销申请完成删除") + return + } + var status int + if err = a.db.QueryRowContext(r.Context(), `SELECT status FROM users WHERE id=? AND deleted_at IS NULL`, who.ID).Scan(&status); err == nil { + status = a.normalizeUserStatus(r.Context(), who.ID, status) + } + if err != nil || status != 1 { + fail(w, http.StatusForbidden, 10006, "账号已被冻结或封禁") + return + } + var forceLogout sql.NullTime + var tokenVersion int + _ = a.db.QueryRowContext(r.Context(), `SELECT force_logout_at,token_version FROM user_security_controls WHERE user_id=?`, who.ID).Scan(&forceLogout, &tokenVersion) + if who.Version != tokenVersion || (forceLogout.Valid && !who.IssuedAt.IsZero() && who.IssuedAt.Unix() < forceLogout.Time.Unix()) { + fail(w, http.StatusUnauthorized, 10001, "登录状态已失效,请重新登录") + return + } + } else if role == "admin" { + var status, tokenVersion int + if err = a.db.QueryRowContext(r.Context(), `SELECT status,token_version FROM admin_users WHERE id=?`, who.ID).Scan(&status, &tokenVersion); err != nil || status != 1 { + fail(w, http.StatusForbidden, 10006, "管理员账号已停用") + return + } + if who.Version != tokenVersion { + fail(w, http.StatusUnauthorized, 10001, "密码已修改,请重新登录") + return + } + } + next(w, r.WithContext(context.WithValue(r.Context(), identityKey{}, who))) + } +} + +func current(r *http.Request) identity { + who, _ := r.Context().Value(identityKey{}).(identity) + return who +} + +// Keep existing bcrypt credentials compatible. Long passwords use Argon2id so +// removing the UI limits never truncates a password or fails at bcrypt's limit. +// Parameters follow OWASP's 19 MiB / 2 iterations / 1 thread recommendation. +const longPasswordHashPrefix = "$argon2id$v=19$m=19456,t=2,p=1$" + +func hashPassword(password string) (string, error) { + if len(password) > 72 { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + key := argon2.IDKey([]byte(password), salt, 2, 19*1024, 1, 32) + return longPasswordHashPrefix + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(key), nil + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(hash), err +} + +func checkPassword(hash, password string) bool { + if strings.HasPrefix(hash, "$argon2id$") { + // Only our supported parameters are accepted; never allocate memory from + // unchecked parameters in a corrupt or externally supplied hash. + payload, ok := strings.CutPrefix(hash, longPasswordHashPrefix) + if !ok || len(payload) != 66 { + return false + } + parts := strings.Split(payload, "$") + if len(parts) != 2 { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[0]) + if err != nil || len(salt) != 16 { + return false + } + expected, err := base64.RawStdEncoding.DecodeString(parts[1]) + if err != nil || len(expected) != 32 { + return false + } + key := argon2.IDKey([]byte(password), salt, 2, 19*1024, 1, 32) + return subtle.ConstantTimeCompare(key, expected) == 1 + } + if len(password) > 72 { + return false + } + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +func validUserPassword(password string) bool { + return password != "" +} + +func validPhone(value string) bool { + value = strings.TrimSpace(value) + if len(value) != 11 || value[0] != '1' || value[1] < '3' || value[1] > '9' { + return false + } + for _, char := range value { + if char < '0' || char > '9' { + return false + } + } + return true +} + +func phoneHash(phone string) []byte { + sum := sha256.Sum256([]byte(strings.TrimSpace(phone))) + return sum[:] +} + +var encryptedPhonePrefix = []byte("enc:v1:") + +func (a *App) piiEncryptionKey() [32]byte { + key := a.config.ConfigEncryptionKey + if key == "" { + key = a.config.JWTSecret + ":development-pii" + } + return sha256.Sum256([]byte(key + ":phone")) +} + +func (a *App) encryptPhone(phone string) ([]byte, error) { + key := a.piiEncryptionKey() + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err = rand.Read(nonce); err != nil { + return nil, err + } + payload := append(append([]byte{}, encryptedPhonePrefix...), nonce...) + payload = gcm.Seal(payload, nonce, []byte(strings.TrimSpace(phone)), nil) + return payload, nil +} + +func (a *App) decryptPhone(payload []byte) (string, error) { + if !bytes.HasPrefix(payload, encryptedPhonePrefix) { + return string(payload), nil + } + key := a.piiEncryptionKey() + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + ciphertext := payload[len(encryptedPhonePrefix):] + if len(ciphertext) < gcm.NonceSize() { + return "", fmt.Errorf("invalid encrypted phone") + } + plain, err := gcm.Open(nil, ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():], nil) + return string(plain), err +} + +func (a *App) encryptLegacyPhones(ctx context.Context) error { + rows, err := a.db.QueryContext(ctx, `SELECT id,phone_cipher FROM users WHERE phone_cipher IS NOT NULL AND LEFT(phone_cipher,7)<>?`, encryptedPhonePrefix) + if err != nil { + return err + } + type legacyPhone struct { + id int64 + payload []byte + } + items := []legacyPhone{} + for rows.Next() { + var item legacyPhone + if err = rows.Scan(&item.id, &item.payload); err != nil { + _ = rows.Close() + return err + } + items = append(items, item) + } + if err = rows.Close(); err != nil { + return err + } + for _, item := range items { + encrypted, encryptErr := a.encryptPhone(string(item.payload)) + if encryptErr != nil { + return encryptErr + } + if _, err = a.db.ExecContext(ctx, `UPDATE users SET phone_cipher=? WHERE id=? AND phone_cipher=?`, encrypted, item.id, item.payload); err != nil { + return err + } + } + return nil +} + +func randomToken() string { + buf := make([]byte, 32) + _, _ = rand.Read(buf) + return hex.EncodeToString(buf) +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/client_productization.go b/im/.deploy/application-20260831-120612/backend/internal/app/client_productization.go new file mode 100644 index 0000000..ed2fb3d --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/client_productization.go @@ -0,0 +1,751 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +type membershipEntitlements struct { + DailyActiveChatLimit int `json:"dailyActiveChatLimit"` + DailyLikeLimit int `json:"dailyLikeLimit"` + CanViewVisitors bool `json:"canViewVisitors"` + CanInvisibleVisit bool `json:"canInvisibleVisit"` + RecommendationWeight int `json:"recommendationWeight"` +} + +func (a *App) validateOwnedImageEvidence(ctx context.Context, userID int64, rawURLs []string) ([]string, error) { + items := make([]string, 0, len(rawURLs)) + seen := map[string]bool{} + for _, rawURL := range rawURLs { + mediaURL := strings.TrimSpace(rawURL) + if mediaURL == "" || seen[mediaURL] { + return nil, fmt.Errorf("证据图片地址无效或重复") + } + seen[mediaURL] = true + var exists int + if err := a.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM media_assets WHERE owner_user_id=? AND public_url=? AND media_type='image' AND status=1 AND moderation_status=1)`, userID, mediaURL).Scan(&exists); err != nil || exists != 1 { + return nil, fmt.Errorf("证据图片必须由当前账号上传") + } + items = append(items, mediaURL) + } + return items, nil +} + +func (a *App) resolveMembershipEntitlements(ctx context.Context, userID int64) membershipEntitlements { + result := membershipEntitlements{DailyActiveChatLimit: a.resolveDailyActiveChatLimit(ctx, a.db, userID), DailyLikeLimit: 20} + _ = a.db.QueryRowContext(ctx, `SELECT CAST(config_value AS UNSIGNED) FROM system_configs WHERE config_key='membership.free_daily_like_limit'`).Scan(&result.DailyLikeLimit) + var visitors, invisible int + _ = a.db.QueryRowContext(ctx, `SELECT p.daily_like_limit,p.can_view_visitors,p.can_invisible_visit,p.recommendation_weight + FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id + WHERE s.user_id=? AND s.status=1 AND s.started_at<=NOW(3) AND s.expires_at>NOW(3) AND p.deleted_at IS NULL + ORDER BY p.level DESC,s.expires_at DESC LIMIT 1`, userID).Scan(&result.DailyLikeLimit, &visitors, &invisible, &result.RecommendationWeight) + result.CanViewVisitors = visitors == 1 + result.CanInvisibleVisit = invisible == 1 + return result +} + +type dailyLikeLimitError struct{ Limit int } + +func (e *dailyLikeLimitError) Error() string { + return fmt.Sprintf("今日点赞次数已达上限(%d次)", e.Limit) +} + +func (a *App) reserveDailyLike(tx *sql.Tx, r *http.Request, targetType string, targetID int64) error { + userID := current(r).ID + if _, err := tx.ExecContext(r.Context(), `INSERT IGNORE INTO user_daily_like_usage(user_id,usage_date,used_count) VALUES(?,CURRENT_DATE(),0)`, userID); err != nil { + return err + } + var used int + if err := tx.QueryRowContext(r.Context(), `SELECT used_count FROM user_daily_like_usage WHERE user_id=? AND usage_date=CURRENT_DATE() FOR UPDATE`, userID).Scan(&used); err != nil { + return err + } + var counted int + if err := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM user_daily_like_targets WHERE user_id=? AND target_type=? AND target_id=? AND usage_date=CURRENT_DATE())`, userID, targetType, targetID).Scan(&counted); err != nil { + return err + } + if counted == 1 { + return nil + } + entitlements := a.resolveMembershipEntitlements(r.Context(), userID) + if entitlements.DailyLikeLimit > 0 && used >= entitlements.DailyLikeLimit { + return &dailyLikeLimitError{Limit: entitlements.DailyLikeLimit} + } + if _, err := tx.ExecContext(r.Context(), `INSERT INTO user_daily_like_targets(user_id,target_type,target_id,usage_date) VALUES(?,?,?,CURRENT_DATE())`, userID, targetType, targetID); err != nil { + return err + } + _, err := tx.ExecContext(r.Context(), `UPDATE user_daily_like_usage SET used_count=used_count+1 WHERE user_id=? AND usage_date=CURRENT_DATE()`, userID) + return err +} + +func pageOptions(r *http.Request) (int, int, int) { + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + pageSize, _ := strconv.Atoi(r.URL.Query().Get("pageSize")) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 50 { + pageSize = 50 + } + return page, pageSize, (page - 1) * pageSize +} + +func nullableInt64(value sql.NullInt64) any { + if value.Valid { + return value.Int64 + } + return nil +} + +func (a *App) availableTags(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,category,name,icon,sort_order FROM tags WHERE status=1 ORDER BY category,sort_order,id`) + if err != nil { + fail(w, 500, 50001, "查询标签失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var category, name, icon string + var order int + if rows.Scan(&id, &category, &name, &icon, &order) == nil { + items = append(items, map[string]any{"id": id, "category": category, "name": name, "icon": icon, "sortOrder": order}) + } + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) myDevices(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT s.id,s.device_id,COALESCE(d.platform,''),COALESCE(d.device_model,''),COALESCE(d.os_version,''),COALESCE(d.app_version,''),COALESCE(d.last_ip,''),s.last_active_at,s.expires_at,s.created_at + FROM user_sessions s LEFT JOIN user_devices d ON d.user_id=s.user_id AND d.device_id=s.device_id + WHERE s.user_id=? AND s.revoked_at IS NULL AND s.expires_at>NOW(3) ORDER BY s.last_active_at DESC`, current(r).ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询登录设备失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var deviceID, platform, model, osVersion, appVersion, ip string + var active, expires, created time.Time + if rows.Scan(&id, &deviceID, &platform, &model, &osVersion, &appVersion, &ip, &active, &expires, &created) == nil { + items = append(items, map[string]any{"id": id, "deviceId": deviceID, "platform": platform, "deviceModel": model, "osVersion": osVersion, "appVersion": appVersion, "lastIp": ip, "lastActiveAt": active, "expiresAt": expires, "createdAt": created}) + } + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) revokeDevice(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "设备会话编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE id=? AND user_id=? AND revoked_at IS NULL`, id, current(r).ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "移除设备失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusNotFound, 30001, "设备会话不存在") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) changeUserPassword(w http.ResponseWriter, r *http.Request) { + var req struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` + } + if decode(r, &req) != nil || req.CurrentPassword == "" || !validUserPassword(req.NewPassword) { + fail(w, http.StatusBadRequest, 20001, "请输入当前密码和新密码") + return + } + var oldHash string + if a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=? AND deleted_at IS NULL`, current(r).ID).Scan(&oldHash) != nil || !checkPassword(oldHash, req.CurrentPassword) { + fail(w, http.StatusUnauthorized, 10001, "当前密码错误") + return + } + newHash, err := hashPassword(req.NewPassword) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "密码加密失败") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "修改密码失败") + return + } + defer func() { _ = tx.Rollback() }() + _, err = tx.ExecContext(r.Context(), `UPDATE users SET password_hash=? WHERE id=?`, newHash, current(r).ID) + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, current(r).ID) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT INTO user_security_controls(user_id,token_version,force_logout_at,password_reset_at) VALUES(?,1,NOW(3),NOW(3)) ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=NOW(3),password_reset_at=NOW(3)`, current(r).ID) + } + if err != nil || tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "修改密码失败") + return + } + a.hub.disconnect(current(r).ID) + reply(w, map[string]bool{"success": true, "reloginRequired": true}) +} + +func (a *App) changeUserPhone(w http.ResponseWriter, r *http.Request) { + var req struct { + Phone string `json:"phone"` + Code string `json:"code"` + CurrentPassword string `json:"currentPassword"` + } + if decode(r, &req) != nil || !validPhone(req.Phone) || len(req.Code) != 6 || req.CurrentPassword == "" { + fail(w, http.StatusBadRequest, 20001, "当前密码、手机号或验证码格式错误") + return + } + var passwordHash string + if a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=? AND deleted_at IS NULL`, current(r).ID).Scan(&passwordHash) != nil || !checkPassword(passwordHash, req.CurrentPassword) { + fail(w, http.StatusUnauthorized, 10001, "当前密码错误") + return + } + if !a.consumeSMSCode(r, req.Phone, "change_phone", req.Code) { + fail(w, http.StatusBadRequest, 20001, "验证码错误或已过期") + return + } + var exists int + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM users WHERE phone_hash=? AND id<>? AND deleted_at IS NULL)`, phoneHash(req.Phone), current(r).ID).Scan(&exists) + if exists == 1 { + fail(w, http.StatusConflict, 20001, "手机号已被其他账号使用") + return + } + cipher, err := a.encryptPhone(req.Phone) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "加密手机号失败") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE users SET phone_hash=?,phone_cipher=? WHERE id=?`, phoneHash(req.Phone), cipher, current(r).ID) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, current(r).ID) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT INTO user_security_controls(user_id,token_version,force_logout_at) VALUES(?,1,NOW(3)) ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=NOW(3)`, current(r).ID) + } + if err != nil || tx.Commit() != nil { + _ = tx.Rollback() + fail(w, http.StatusInternalServerError, 50001, "更换手机号失败") + return + } + a.hub.disconnect(current(r).ID) + reply(w, map[string]bool{"success": true, "reloginRequired": true}) +} + +func (a *App) blockedUsers(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT blocked_user_id FROM user_blocks WHERE user_id=? ORDER BY created_at DESC`, current(r).ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询黑名单失败") + return + } + defer rows.Close() + items := []profileView{} + for rows.Next() { + var id int64 + _ = rows.Scan(&id) + if item, loadErr := a.loadProfile(r, id, current(r).ID); loadErr == nil { + items = append(items, item) + } + } + reply(w, map[string]any{"items": items}) +} + +type notificationSettingsView struct { + IMEnabled bool `json:"imEnabled"` + InteractionEnabled bool `json:"interactionEnabled"` + SystemEnabled bool `json:"systemEnabled"` + SoundEnabled bool `json:"soundEnabled"` + VibrationEnabled bool `json:"vibrationEnabled"` + QuietStart string `json:"quietStart"` + QuietEnd string `json:"quietEnd"` +} + +func (a *App) notificationAllowed(ctx context.Context, userID int64, notificationType string) bool { + var enabled int + column := "interaction_enabled" + if notificationType == "system" { + column = "system_enabled" + } else if notificationType == "im" { + column = "im_enabled" + } + err := a.db.QueryRowContext(ctx, `SELECT `+column+` FROM user_notification_settings WHERE user_id=?`, userID).Scan(&enabled) + return err == sql.ErrNoRows || (err == nil && enabled == 1) +} + +func (a *App) notifyUser(ctx context.Context, userID int64, notificationType, title, content, bizType string, bizID any) { + if userID <= 0 || !a.notificationAllowed(ctx, userID, notificationType) { + return + } + _, _ = a.db.ExecContext(ctx, `INSERT INTO notifications(user_id,type,title,content,biz_type,biz_id) VALUES(?,?,?,?,?,?)`, userID, notificationType, title, content, bizType, bizID) +} + +func (a *App) notificationSettings(w http.ResponseWriter, r *http.Request) { + _, _ = a.db.ExecContext(r.Context(), `INSERT IGNORE INTO user_notification_settings(user_id) VALUES(?)`, current(r).ID) + var item notificationSettingsView + if err := a.db.QueryRowContext(r.Context(), `SELECT im_enabled,interaction_enabled,system_enabled,sound_enabled,vibration_enabled,quiet_start,quiet_end FROM user_notification_settings WHERE user_id=?`, current(r).ID).Scan(&item.IMEnabled, &item.InteractionEnabled, &item.SystemEnabled, &item.SoundEnabled, &item.VibrationEnabled, &item.QuietStart, &item.QuietEnd); err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询通知设置失败") + return + } + reply(w, item) +} + +func validClock(value string) bool { + if value == "" { + return true + } + if len(value) != 5 || value[2] != ':' { + return false + } + hour, hErr := strconv.Atoi(value[:2]) + minute, mErr := strconv.Atoi(value[3:]) + return hErr == nil && mErr == nil && hour >= 0 && hour < 24 && minute >= 0 && minute < 60 +} + +func (a *App) updateNotificationSettings(w http.ResponseWriter, r *http.Request) { + var req notificationSettingsView + if decode(r, &req) != nil || !validClock(req.QuietStart) || !validClock(req.QuietEnd) { + fail(w, http.StatusBadRequest, 20001, "通知设置格式错误") + return + } + _, err := a.db.ExecContext(r.Context(), `INSERT INTO user_notification_settings(user_id,im_enabled,interaction_enabled,system_enabled,sound_enabled,vibration_enabled,quiet_start,quiet_end) VALUES(?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE im_enabled=VALUES(im_enabled),interaction_enabled=VALUES(interaction_enabled),system_enabled=VALUES(system_enabled),sound_enabled=VALUES(sound_enabled),vibration_enabled=VALUES(vibration_enabled),quiet_start=VALUES(quiet_start),quiet_end=VALUES(quiet_end)`, current(r).ID, req.IMEnabled, req.InteractionEnabled, req.SystemEnabled, req.SoundEnabled, req.VibrationEnabled, req.QuietStart, req.QuietEnd) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存通知设置失败") + return + } + reply(w, req) +} + +func (a *App) registerPushToken(w http.ResponseWriter, r *http.Request) { + var req struct { + DeviceID string `json:"deviceId"` + Provider string `json:"provider"` + PushToken string `json:"pushToken"` + Platform string `json:"platform"` + AppVersion string `json:"appVersion"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.DeviceID) == "" || strings.TrimSpace(req.Provider) == "" || strings.TrimSpace(req.PushToken) == "" || len(req.PushToken) > 500 { + fail(w, http.StatusBadRequest, 20001, "推送设备信息不完整") + return + } + _, err := a.db.ExecContext(r.Context(), `INSERT INTO user_push_tokens(user_id,device_id,provider,push_token,platform,app_version,status,last_active_at) VALUES(?,?,?,?,?,?,1,NOW(3)) ON DUPLICATE KEY UPDATE push_token=VALUES(push_token),platform=VALUES(platform),app_version=VALUES(app_version),status=1,last_active_at=NOW(3)`, current(r).ID, req.DeviceID, req.Provider, req.PushToken, req.Platform, req.AppVersion) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存推送设备失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) deletePushToken(w http.ResponseWriter, r *http.Request) { + deviceID := strings.TrimSpace(r.URL.Query().Get("deviceId")) + if deviceID == "" { + fail(w, http.StatusBadRequest, 20001, "设备标识不能为空") + return + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE user_push_tokens SET status=0 WHERE user_id=? AND device_id=?`, current(r).ID, deviceID) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) feedback(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,category,content,contact,evidence_json,status,reply_content,created_at,updated_at FROM user_feedback WHERE user_id=? ORDER BY created_at DESC LIMIT 100`, current(r).ID) + if err != nil { + fail(w, 500, 50001, "查询反馈失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var category, content, contact, evidenceJSON, status, reply string + var created, updated time.Time + if rows.Scan(&id, &category, &content, &contact, &evidenceJSON, &status, &reply, &created, &updated) == nil { + var evidence []string + _ = json.Unmarshal([]byte(evidenceJSON), &evidence) + items = append(items, map[string]any{"id": id, "category": category, "content": content, "contact": contact, "evidence": evidence, "status": status, "reply": reply, "createdAt": created, "updatedAt": updated}) + } + } + reply(w, map[string]any{"items": items}) + return + } + var req struct { + Category string `json:"category"` + Content string `json:"content"` + Contact string `json:"contact"` + Evidence []string `json:"evidence"` + } + req.Category = strings.ToLower(strings.TrimSpace(req.Category)) + if decode(r, &req) != nil { + fail(w, 400, 20001, "反馈格式错误") + return + } + req.Category = strings.ToLower(strings.TrimSpace(req.Category)) + req.Content = strings.TrimSpace(req.Content) + allowed := map[string]bool{"bug": true, "suggestion": true, "complaint": true, "other": true} + if !allowed[req.Category] || len([]rune(req.Content)) < 5 || len([]rune(req.Content)) > 2000 || len(req.Evidence) > 6 { + fail(w, http.StatusBadRequest, 20001, "请选择反馈类型并填写 5-2000 字内容") + return + } + evidence, evidenceErr := a.validateOwnedImageEvidence(r.Context(), current(r).ID, req.Evidence) + if evidenceErr != nil { + fail(w, http.StatusBadRequest, 20001, evidenceErr.Error()) + return + } + evidenceJSON, _ := json.Marshal(evidence) + result, err := a.db.ExecContext(r.Context(), `INSERT INTO user_feedback(user_id,category,content,contact,evidence_json) VALUES(?,?,?,?,?)`, current(r).ID, req.Category, req.Content, strings.TrimSpace(req.Contact), evidenceJSON) + if err != nil { + fail(w, 500, 50001, "提交反馈失败") + return + } + id, _ := result.LastInsertId() + reply(w, map[string]any{"id": id, "status": "PENDING"}) +} + +func (a *App) accountClosure(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + var status, reason string + var requested, executeAfter time.Time + var cancelled, completed sql.NullTime + err := a.db.QueryRowContext(r.Context(), `SELECT status,reason,requested_at,execute_after,cancelled_at,completed_at FROM user_account_closures WHERE user_id=?`, current(r).ID).Scan(&status, &reason, &requested, &executeAfter, &cancelled, &completed) + if err == sql.ErrNoRows { + reply(w, map[string]any{"status": "NONE"}) + return + } + if err != nil { + fail(w, 500, 50001, "查询注销状态失败") + return + } + reply(w, map[string]any{"status": status, "reason": reason, "requestedAt": requested, "executeAfter": executeAfter, "cancelledAt": nullableTime(cancelled), "completedAt": nullableTime(completed)}) + return + } + if r.Method == http.MethodDelete { + result, err := a.db.ExecContext(r.Context(), `UPDATE user_account_closures SET status='CANCELLED',cancelled_at=NOW(3) WHERE user_id=? AND status='PENDING' AND execute_after>NOW(3)`, current(r).ID) + if err != nil { + fail(w, 500, 50001, "取消注销失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 409, 20001, "当前没有可取消的注销申请") + return + } + reply(w, map[string]bool{"success": true}) + return + } + var req struct { + Password string `json:"password"` + Reason string `json:"reason"` + } + if decode(r, &req) != nil || req.Password == "" { + fail(w, 400, 20001, "请输入当前密码确认注销") + return + } + var passwordHash string + if a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, current(r).ID).Scan(&passwordHash) != nil || !checkPassword(passwordHash, req.Password) { + fail(w, 401, 10001, "当前密码错误") + return + } + days, _ := strconv.Atoi(a.configPlain(r.Context(), "account.cancellation_cooling_days", "7")) + if days < 1 { + days = 7 + } + _, err := a.db.ExecContext(r.Context(), `INSERT INTO user_account_closures(user_id,reason,status,requested_at,execute_after,cancelled_at,completed_at) VALUES(?,?,'PENDING',NOW(3),DATE_ADD(NOW(3),INTERVAL ? DAY),NULL,NULL) ON DUPLICATE KEY UPDATE reason=VALUES(reason),status='PENDING',requested_at=NOW(3),execute_after=VALUES(execute_after),cancelled_at=NULL,completed_at=NULL`, current(r).ID, strings.TrimSpace(req.Reason), days) + if err != nil { + fail(w, 500, 50001, "提交注销申请失败") + return + } + reply(w, map[string]any{"status": "PENDING", "coolingDays": days}) +} + +func (a *App) executeDueAccountClosureContext(ctx context.Context, userID int64) bool { + tx, err := a.db.BeginTx(ctx, nil) + if err != nil { + return false + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(ctx, `UPDATE user_account_closures SET status='COMPLETED',completed_at=NOW(3) WHERE user_id=? AND status='PENDING' AND execute_after<=NOW(3)`, userID) + if err != nil { + return false + } + affected, _ := result.RowsAffected() + if affected == 0 { + return false + } + _, err = tx.ExecContext(ctx, `UPDATE users SET status=0,deleted_at=NOW(3),phone_hash=NULL,phone_cipher=NULL,password_hash='' WHERE id=?`, userID) + if err == nil { + _, err = tx.ExecContext(ctx, `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `UPDATE user_profiles SET nickname='已注销用户',avatar_url='',cover_url='',gender=0,birthday=NULL,height_cm=NULL,city_code='',city_name='',occupation='',education=0,relationship_status=0,bio='',is_vip=0,vip_level=0,last_active_at=NULL WHERE user_id=?`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `UPDATE posts SET status=0,deleted_at=COALESCE(deleted_at,NOW(3)) WHERE user_id=?`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `DELETE FROM user_location_states WHERE user_id=?`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `DELETE FROM user_push_tokens WHERE user_id=?`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `DELETE FROM user_oauth_identities WHERE user_id=?`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `DELETE FROM user_verifications WHERE user_id=?`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `UPDATE user_feedback SET contact='',evidence_json=JSON_ARRAY() WHERE user_id=?`, userID) + } + if err != nil || tx.Commit() != nil { + return false + } + a.hub.disconnect(userID) + return true +} + +func (a *App) executeDueAccountClosure(r *http.Request, userID int64) bool { + return a.executeDueAccountClosureContext(r.Context(), userID) +} + +func (a *App) processDueAccountClosures(ctx context.Context) { + rows, err := a.db.QueryContext(ctx, `SELECT user_id FROM user_account_closures WHERE status='PENDING' AND execute_after<=NOW(3) ORDER BY execute_after LIMIT 100`) + if err != nil { + return + } + ids := []int64{} + for rows.Next() { + var id int64 + if rows.Scan(&id) == nil { + ids = append(ids, id) + } + } + _ = rows.Close() + for _, id := range ids { + a.executeDueAccountClosureContext(ctx, id) + } +} + +func (a *App) exportMyData(w http.ResponseWriter, r *http.Request) { + profile, err := a.loadProfile(r, current(r).ID, current(r).ID) + if err != nil { + fail(w, 404, 30001, "用户不存在") + return + } + var privacy privacyView + var p [8]int + _ = a.db.QueryRowContext(r.Context(), `SELECT nearby_visible,distance_visible,online_visible,last_active_visible,allow_stranger_message,allow_profile_visit_record,allow_search,invisible_visit FROM user_privacy_settings WHERE user_id=?`, current(r).ID).Scan(&p[0], &p[1], &p[2], &p[3], &p[4], &p[5], &p[6], &p[7]) + privacy = privacyView{NearbyVisible: p[0] == 1, DistanceVisible: p[1] == 1, OnlineVisible: p[2] == 1, LastActiveVisible: p[3] == 1, AllowStrangerMessage: p[4] == 1, AllowProfileVisitRecord: p[5] == 1, AllowSearch: p[6] == 1, InvisibleVisit: p[7] == 1} + var posts, messages, orders int + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM posts WHERE user_id=? AND deleted_at IS NULL`, current(r).ID).Scan(&posts) + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM im_messages WHERE sender_id=?`, current(r).ID).Scan(&messages) + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM orders WHERE user_id=? AND deleted_at IS NULL`, current(r).ID).Scan(&orders) + reply(w, map[string]any{"exportedAt": time.Now(), "profile": profile, "privacy": privacy, "statistics": map[string]int{"posts": posts, "messages": messages, "orders": orders}, "notice": "聊天内容、订单明细和认证材料涉及敏感信息,请通过客服完成加密归档导出。"}) +} + +func (a *App) recordConsent(w http.ResponseWriter, r *http.Request) { + var req struct { + Type string `json:"type"` + Version string `json:"version"` + DeviceID string `json:"deviceId"` + } + if decode(r, &req) != nil || (req.Type != "user_agreement" && req.Type != "privacy_policy") || strings.TrimSpace(req.Version) == "" { + fail(w, 400, 20001, "协议确认信息无效") + return + } + _, err := a.db.ExecContext(r.Context(), `INSERT IGNORE INTO user_consents(user_id,agreement_type,agreement_version,ip,device_id) VALUES(?,?,?,?,?)`, current(r).ID, req.Type, strings.TrimSpace(req.Version), clientIP(r), strings.TrimSpace(req.DeviceID)) + if err != nil { + fail(w, 500, 50001, "保存协议确认失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) updatePost(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态编号无效") + return + } + var req struct { + Content string `json:"content"` + Visibility int `json:"visibility"` + Location string `json:"location"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.Content) == "" || len([]rune(req.Content)) > 2000 { + fail(w, 400, 20001, "动态内容需为 1-2000 字") + return + } + if req.Visibility != 2 { + req.Visibility = 1 + } + result, err := a.db.ExecContext(r.Context(), `UPDATE posts SET content=?,visibility=?,location_text=?,moderation_status=1 WHERE id=? AND user_id=? AND status=1`, strings.TrimSpace(req.Content), req.Visibility, strings.TrimSpace(req.Location), id, current(r).ID) + if err != nil { + fail(w, 500, 50001, "修改动态失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 404, 30001, "动态不存在或无权修改") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) deleteOwnPost(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE posts SET status=0,deleted_at=NOW(3) WHERE id=? AND user_id=? AND status=1`, id, current(r).ID) + if err != nil { + fail(w, 500, 50001, "删除动态失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 404, 30001, "动态不存在或无权删除") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) deleteOwnComment(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "评论编号无效") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "删除评论失败") + return + } + defer func() { _ = tx.Rollback() }() + var postID int64 + if err = tx.QueryRowContext(r.Context(), `SELECT c.post_id FROM post_comments c JOIN posts p ON p.id=c.post_id WHERE c.id=? AND (c.user_id=? OR p.user_id=?) AND c.status=1 FOR UPDATE`, id, current(r).ID, current(r).ID).Scan(&postID); err != nil { + fail(w, 404, 30001, "评论不存在或无权删除") + return + } + _, err = tx.ExecContext(r.Context(), `UPDATE post_comments SET status=0,deleted_at=NOW(3) WHERE id=?`, id) + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE posts SET comment_count=GREATEST(comment_count-1,0) WHERE id=?`, postID) + } + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "删除评论失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) readNotification(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "通知编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE notifications SET read_at=COALESCE(read_at,NOW(3)) WHERE id=? AND user_id=?`, id, current(r).ID) + if err != nil { + fail(w, 500, 50001, "更新通知失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 404, 30001, "通知不存在") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) myReports(w http.ResponseWriter, r *http.Request) { + page, pageSize, offset := pageOptions(r) + var total int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM reports WHERE reporter_user_id=?`, current(r).ID).Scan(&total); err != nil { + fail(w, 500, 50001, "查询举报记录失败") + return + } + rows, err := a.db.QueryContext(r.Context(), `SELECT id,target_type,target_id,reason_code,description,evidence_json,status,action_type,handle_remark,created_at,handled_at FROM reports WHERE reporter_user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?`, current(r).ID, pageSize, offset) + if err != nil { + fail(w, 500, 50001, "查询举报记录失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, targetID int64 + var typ, reason, description, evidenceJSON, status, actionType, handleRemark string + var created time.Time + var handled sql.NullTime + if rows.Scan(&id, &typ, &targetID, &reason, &description, &evidenceJSON, &status, &actionType, &handleRemark, &created, &handled) == nil { + var evidence []string + _ = json.Unmarshal([]byte(evidenceJSON), &evidence) + items = append(items, map[string]any{"id": id, "targetType": typ, "targetId": targetID, "reason": reason, "description": description, "evidence": evidence, "status": status, "actionType": actionType, "handleRemark": handleRemark, "createdAt": created, "handledAt": nullableTime(handled)}) + } + } + reply(w, map[string]any{"items": items, "page": page, "pageSize": pageSize, "total": total, "hasMore": offset+len(items) < total}) +} + +func (a *App) closeOwnOrder(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "订单编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE orders SET status='CLOSED' WHERE id=? AND user_id=? AND status='CREATED' AND deleted_at IS NULL`, id, current(r).ID) + if err != nil { + fail(w, 500, 50001, "关闭订单失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 409, 20001, "当前订单状态无法关闭") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) requestOrderRefund(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "订单编号无效") + return + } + var req struct { + Reason string `json:"reason"` + } + if decode(r, &req) != nil || len([]rune(strings.TrimSpace(req.Reason))) < 2 { + fail(w, 400, 20001, "请填写退款原因") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE orders SET status='REFUND_REQUESTED',refund_reason=?,refund_requested_at=NOW(3) WHERE id=? AND user_id=? AND status='PAID' AND deleted_at IS NULL`, strings.TrimSpace(req.Reason), id, current(r).ID) + if err != nil { + fail(w, 500, 50001, "提交退款申请失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 409, 20001, "当前订单状态无法申请退款") + return + } + reply(w, map[string]bool{"success": true}) +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/client_productization_test.go b/im/.deploy/application-20260831-120612/backend/internal/app/client_productization_test.go new file mode 100644 index 0000000..50a640a --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/client_productization_test.go @@ -0,0 +1,35 @@ +package app + +import "testing" + +func TestValidClock(t *testing.T) { + tests := map[string]bool{ + "": true, + "00:00": true, + "09:30": true, + "23:59": true, + "24:00": false, + "12:60": false, + "9:30": false, + "noon": false, + } + for value, expected := range tests { + if actual := validClock(value); actual != expected { + t.Fatalf("validClock(%q) = %v, want %v", value, actual, expected) + } + } +} + +func TestOrderEntitlementActive(t *testing.T) { + active := []string{"PAID", "REFUND_REQUESTED", "REFUNDING"} + for _, status := range active { + if !orderEntitlementActive(status) { + t.Fatalf("status %s must keep the membership entitlement active", status) + } + } + for _, status := range []string{"CREATED", "REFUNDED", "CLOSED", "UNKNOWN"} { + if orderEntitlementActive(status) { + t.Fatalf("status %s must not grant a membership entitlement", status) + } + } +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/extensions.go b/im/.deploy/application-20260831-120612/backend/internal/app/extensions.go new file mode 100644 index 0000000..15e3642 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/extensions.go @@ -0,0 +1,244 @@ +package app + +import ( + "database/sql" + "net/http" + "strings" +) + +func (a *App) searchUsers(w http.ResponseWriter, r *http.Request) { + keyword := strings.TrimSpace(r.URL.Query().Get("q")) + if keyword == "" { + reply(w, map[string]any{"items": []profileView{}, "total": 0}) + return + } + pattern := "%" + keyword + "%" + page, pageSize, offset := pageOptions(r) + var total int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users u JOIN user_profiles p ON p.user_id=u.id JOIN user_privacy_settings privacy ON privacy.user_id=u.id WHERE u.status=1 AND u.id<>? AND privacy.allow_search=1 AND (p.nickname LIKE ? OR u.public_id LIKE ?) AND NOT EXISTS(SELECT 1 FROM user_blocks b WHERE (b.user_id=? AND b.blocked_user_id=u.id) OR (b.user_id=u.id AND b.blocked_user_id=?))`, current(r).ID, pattern, pattern, current(r).ID, current(r).ID).Scan(&total); err != nil { + fail(w, 500, 50001, "搜索失败") + return + } + rows, err := a.db.QueryContext(r.Context(), `SELECT u.id FROM users u JOIN user_profiles p ON p.user_id=u.id JOIN user_privacy_settings privacy ON privacy.user_id=u.id WHERE u.status=1 AND u.id<>? AND privacy.allow_search=1 AND (p.nickname LIKE ? OR u.public_id LIKE ?) AND NOT EXISTS(SELECT 1 FROM user_blocks b WHERE (b.user_id=? AND b.blocked_user_id=u.id) OR (b.user_id=u.id AND b.blocked_user_id=?)) ORDER BY p.is_vip DESC,p.last_active_at DESC LIMIT ? OFFSET ?`, current(r).ID, pattern, pattern, current(r).ID, current(r).ID, pageSize, offset) + if err != nil { + fail(w, 500, 50001, "搜索失败") + return + } + defer rows.Close() + items := []profileView{} + for rows.Next() { + var id int64 + _ = rows.Scan(&id) + if item, loadErr := a.loadProfile(r, id, current(r).ID); loadErr == nil { + items = append(items, item) + } + } + reply(w, map[string]any{"items": items, "total": total, "keyword": keyword, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) +} + +func (a *App) followingList(w http.ResponseWriter, r *http.Request) { + a.relationshipList(w, r, "following") +} + +func (a *App) followerList(w http.ResponseWriter, r *http.Request) { + a.relationshipList(w, r, "followers") +} + +func (a *App) visitorList(w http.ResponseWriter, r *http.Request) { + if !a.resolveMembershipEntitlements(r.Context(), current(r).ID).CanViewVisitors { + fail(w, http.StatusForbidden, 10006, "开通会员后可查看访客记录") + return + } + a.relationshipList(w, r, "visitors") +} + +func (a *App) relationshipList(w http.ResponseWriter, r *http.Request, listType string) { + var rows *sql.Rows + var err error + page, pageSize, offset := pageOptions(r) + var total int + switch listType { + case "following": + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_follows WHERE user_id=?`, current(r).ID).Scan(&total) + rows, err = a.db.QueryContext(r.Context(), `SELECT target_user_id FROM user_follows WHERE user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?`, current(r).ID, pageSize, offset) + case "followers": + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_follows WHERE target_user_id=?`, current(r).ID).Scan(&total) + rows, err = a.db.QueryContext(r.Context(), `SELECT user_id FROM user_follows WHERE target_user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?`, current(r).ID, pageSize, offset) + default: + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(DISTINCT viewer_user_id) FROM profile_visits WHERE target_user_id=?`, current(r).ID).Scan(&total) + rows, err = a.db.QueryContext(r.Context(), `SELECT viewer_user_id FROM profile_visits WHERE target_user_id=? GROUP BY viewer_user_id ORDER BY MAX(visited_at) DESC LIMIT ? OFFSET ?`, current(r).ID, pageSize, offset) + } + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []profileView{} + for rows.Next() { + var id int64 + _ = rows.Scan(&id) + if item, loadErr := a.loadProfile(r, id, current(r).ID); loadErr == nil { + items = append(items, item) + } + } + reply(w, map[string]any{"items": items, "total": total, "type": listType, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) +} + +type privacyView struct { + NearbyVisible bool `json:"nearbyVisible"` + DistanceVisible bool `json:"distanceVisible"` + OnlineVisible bool `json:"onlineVisible"` + LastActiveVisible bool `json:"lastActiveVisible"` + AllowStrangerMessage bool `json:"allowStrangerMessage"` + AllowProfileVisitRecord bool `json:"allowProfileVisitRecord"` + InvisibleVisit bool `json:"invisibleVisit"` + AllowSearch bool `json:"allowSearch"` +} + +func (a *App) getPrivacy(w http.ResponseWriter, r *http.Request) { + var values [8]int + err := a.db.QueryRowContext(r.Context(), `SELECT nearby_visible,distance_visible,online_visible,last_active_visible,allow_stranger_message,allow_profile_visit_record,invisible_visit,allow_search FROM user_privacy_settings WHERE user_id=?`, current(r).ID).Scan(&values[0], &values[1], &values[2], &values[3], &values[4], &values[5], &values[6], &values[7]) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + reply(w, privacyView{NearbyVisible: values[0] == 1, DistanceVisible: values[1] == 1, OnlineVisible: values[2] == 1, LastActiveVisible: values[3] == 1, AllowStrangerMessage: values[4] == 1, AllowProfileVisitRecord: values[5] == 1, InvisibleVisit: values[6] == 1, AllowSearch: values[7] == 1}) +} + +func (a *App) updatePrivacy(w http.ResponseWriter, r *http.Request) { + var req privacyView + if decode(r, &req) != nil { + fail(w, 400, 20001, "隐私设置格式错误") + return + } + if req.InvisibleVisit && !a.resolveMembershipEntitlements(r.Context(), current(r).ID).CanInvisibleVisit { + fail(w, http.StatusForbidden, 10006, "当前会员等级不支持隐身访问") + return + } + _, err := a.db.ExecContext(r.Context(), `UPDATE user_privacy_settings SET nearby_visible=?,distance_visible=?,online_visible=?,last_active_visible=?,allow_stranger_message=?,allow_profile_visit_record=?,invisible_visit=?,allow_search=? WHERE user_id=?`, req.NearbyVisible, req.DistanceVisible, req.OnlineVisible, req.LastActiveVisible, req.AllowStrangerMessage, req.AllowProfileVisitRecord, req.InvisibleVisit, req.AllowSearch, current(r).ID) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + reply(w, req) +} + +func (a *App) blockUser(w http.ResponseWriter, r *http.Request) { + target, err := pathID(r) + if err != nil || target == current(r).ID { + fail(w, 400, 20001, "无效用户") + return + } + var targetExists int + if err = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM users WHERE id=? AND status=1 AND deleted_at IS NULL)`, target).Scan(&targetExists); err != nil || targetExists != 1 { + fail(w, http.StatusNotFound, 30001, "用户不存在") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "拉黑失败") + return + } + defer tx.Rollback() + _, err = tx.ExecContext(r.Context(), `INSERT IGNORE INTO user_blocks(user_id,blocked_user_id,reason)VALUES(?,?,'user_action')`, current(r).ID, target) + if err == nil { + _, err = tx.ExecContext(r.Context(), `DELETE FROM user_follows WHERE (user_id=? AND target_user_id=?) OR (user_id=? AND target_user_id=?)`, current(r).ID, target, target, current(r).ID) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `DELETE FROM user_likes WHERE (user_id=? AND target_user_id=?) OR (user_id=? AND target_user_id=?)`, current(r).ID, target, target, current(r).ID) + } + if err == nil { + err = tx.Commit() + } + if err != nil { + fail(w, 500, 50001, "拉黑失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) unblockUser(w http.ResponseWriter, r *http.Request) { + target, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "无效用户") + return + } + _, err = a.db.ExecContext(r.Context(), `DELETE FROM user_blocks WHERE user_id=? AND blocked_user_id=?`, current(r).ID, target) + if err != nil { + fail(w, 500, 50001, "解除拉黑失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) loadPost(r *http.Request, id int64) (postView, error) { + var item postView + var userID int64 + var vip int + err := a.db.QueryRowContext(r.Context(), `SELECT p.id,p.user_id,p.content,p.location_text,p.like_count,p.comment_count,p.created_at,u.public_id,pr.nickname,pr.avatar_url,pr.gender,pr.is_vip,EXISTS(SELECT 1 FROM post_likes l WHERE l.post_id=p.id AND l.user_id=?) FROM posts p JOIN users u ON u.id=p.user_id JOIN user_profiles pr ON pr.user_id=p.user_id WHERE p.id=? AND p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id)))`, current(r).ID, id, current(r).ID, current(r).ID).Scan(&item.ID, &userID, &item.Content, &item.Location, &item.LikeCount, &item.CommentCount, &item.CreatedAt, &item.User.PublicID, &item.User.Nickname, &item.User.Avatar, &item.User.Gender, &vip, &item.Liked) + if err != nil { + return item, err + } + item.User.ID = userID + item.User.VIP = vip == 1 + item.CanEdit = userID == current(r).ID + item.CanDelete = userID == current(r).ID + item.Media = []string{} + rows, _ := a.db.QueryContext(r.Context(), `SELECT media_url FROM post_media WHERE post_id=? ORDER BY sort_order`, id) + if rows != nil { + defer rows.Close() + for rows.Next() { + var media string + _ = rows.Scan(&media) + item.Media = append(item.Media, media) + } + } + return item, nil +} + +func (a *App) postDetail(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态编号无效") + return + } + item, err := a.loadPost(r, id) + if err != nil { + fail(w, 404, 30001, "动态不存在") + return + } + reply(w, item) +} + +func (a *App) userPosts(w http.ResponseWriter, r *http.Request) { + userID, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "用户编号无效") + return + } + page, pageSize, offset := pageOptions(r) + var total int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM posts WHERE user_id=? AND status=1 AND (visibility=1 OR user_id=? OR (visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=posts.user_id)))`, userID, current(r).ID, current(r).ID).Scan(&total); err != nil { + fail(w, 500, 50001, "查询失败") + return + } + rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM posts WHERE user_id=? AND status=1 AND (visibility=1 OR user_id=? OR (visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=posts.user_id))) ORDER BY created_at DESC LIMIT ? OFFSET ?`, userID, current(r).ID, current(r).ID, pageSize, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + ids := []int64{} + for rows.Next() { + var id int64 + _ = rows.Scan(&id) + ids = append(ids, id) + } + items := []postView{} + for _, id := range ids { + if item, loadErr := a.loadPost(r, id); loadErr == nil { + items = append(items, item) + } + } + reply(w, map[string]any{"items": items, "total": total, "userId": userID, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/im.go b/im/.deploy/application-20260831-120612/backend/internal/app/im.go new file mode 100644 index 0000000..a284f17 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/im.go @@ -0,0 +1,705 @@ +package app + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/gorilla/websocket" +) + +type messageView struct { + ID int64 `json:"id"` + ConversationID int64 `json:"conversationId"` + Seq int64 `json:"seq"` + SenderID int64 `json:"senderId"` + ClientMsgID string `json:"clientMsgId"` + Type int `json:"type"` + Content any `json:"content"` + Recalled bool `json:"recalled"` + CreatedAt time.Time `json:"createdAt"` +} + +const ( + maxMessageBodyBytes = 16 << 10 + maxMessageTextRunes = 2000 +) + +var clientMessageIDPattern = regexp.MustCompile(`^[A-Za-z0-9_.:-]{1,64}$`) + +func validMessageMediaURL(raw string, production bool) bool { + raw = strings.TrimSpace(raw) + if raw == "" || len(raw) > 2048 { + return false + } + if strings.HasPrefix(raw, "/uploads/") { + return !strings.Contains(raw, "..") + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return false + } + return !production || parsed.Scheme == "https" +} + +func numericDuration(value any) (int, bool) { + switch duration := value.(type) { + case float64: + return int(duration), duration == float64(int(duration)) + case int: + return duration, true + case int64: + return int(duration), true + case json.Number: + parsed, err := strconv.Atoi(duration.String()) + return parsed, err == nil + default: + return 0, false + } +} + +func (a *App) validateMessagePayload(clientMsgID string, messageType int, content any) (string, any, []byte, error) { + clientMsgID = strings.TrimSpace(clientMsgID) + if clientMsgID == "" { + clientMsgID = randomToken()[:26] + } + if !clientMessageIDPattern.MatchString(clientMsgID) { + return "", nil, nil, fmt.Errorf("客户端消息 ID 格式无效") + } + if messageType == 0 { + messageType = 1 + } + data, ok := content.(map[string]any) + if !ok { + return "", nil, nil, fmt.Errorf("消息内容格式错误") + } + switch messageType { + case 1: + text, ok := data["text"].(string) + text = strings.TrimSpace(text) + if !ok || text == "" || !utf8.ValidString(text) || utf8.RuneCountInString(text) > maxMessageTextRunes { + return "", nil, nil, fmt.Errorf("文本消息应为 1 至 %d 个字符", maxMessageTextRunes) + } + data = map[string]any{"text": text} + case 2: + mediaURL, ok := data["url"].(string) + if !ok || !validMessageMediaURL(mediaURL, a.config.Environment == "production") { + return "", nil, nil, fmt.Errorf("图片地址无效") + } + data = map[string]any{"url": strings.TrimSpace(mediaURL)} + case 3: + mediaURL, ok := data["url"].(string) + duration, durationOK := numericDuration(data["duration"]) + if !ok || !validMessageMediaURL(mediaURL, a.config.Environment == "production") || !durationOK || duration < 1 || duration > 60 { + return "", nil, nil, fmt.Errorf("语音消息地址或时长无效") + } + data = map[string]any{"duration": duration, "url": strings.TrimSpace(mediaURL)} + default: + return "", nil, nil, fmt.Errorf("不支持的消息类型") + } + body, err := json.Marshal(data) + if err != nil || len(body) > maxMessageBodyBytes { + return "", nil, nil, fmt.Errorf("消息内容过大") + } + return clientMsgID, data, body, nil +} + +type wsClient struct { + userID int64 + conn *websocket.Conn + mu sync.Mutex +} + +func (c *wsClient) writeJSON(payload any) error { + c.mu.Lock() + defer c.mu.Unlock() + _ = c.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + return c.conn.WriteJSON(payload) +} + +type Hub struct { + mu sync.RWMutex + clients map[int64]map[*wsClient]struct{} +} + +func NewHub() *Hub { return &Hub{clients: make(map[int64]map[*wsClient]struct{})} } + +func (h *Hub) add(client *wsClient) { + h.mu.Lock() + defer h.mu.Unlock() + if h.clients[client.userID] == nil { + h.clients[client.userID] = make(map[*wsClient]struct{}) + } + h.clients[client.userID][client] = struct{}{} +} + +func (h *Hub) remove(client *wsClient) { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.clients[client.userID], client) + if len(h.clients[client.userID]) == 0 { + delete(h.clients, client.userID) + } +} + +func (h *Hub) broadcast(userIDs []int64, payload any) { + h.mu.RLock() + targets := []*wsClient{} + for _, userID := range userIDs { + for client := range h.clients[userID] { + targets = append(targets, client) + } + } + h.mu.RUnlock() + for _, client := range targets { + _ = client.writeJSON(payload) + } +} + +func (h *Hub) disconnect(userID int64) { + h.mu.RLock() + targets := []*wsClient{} + for client := range h.clients[userID] { + targets = append(targets, client) + } + h.mu.RUnlock() + for _, client := range targets { + client.mu.Lock() + _ = client.conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "登录状态已失效"), time.Now().Add(time.Second)) + _ = client.conn.Close() + client.mu.Unlock() + } +} + +func (a *App) directConversation(w http.ResponseWriter, r *http.Request) { + var req struct { + UserID int64 `json:"userId"` + } + if decode(r, &req) != nil || req.UserID == 0 || req.UserID == current(r).ID { + fail(w, 400, 20001, "无效的聊天对象") + return + } + first, second := current(r).ID, req.UserID + if first > second { + first, second = second, first + } + var targetStatus, allowStranger int + if err := a.db.QueryRowContext(r.Context(), `SELECT u.status,p.allow_stranger_message FROM users u JOIN user_privacy_settings p ON p.user_id=u.id WHERE u.id=? AND u.deleted_at IS NULL`, req.UserID).Scan(&targetStatus, &allowStranger); err != nil || targetStatus != 1 { + fail(w, http.StatusNotFound, 30001, "聊天对象不存在或不可用") + return + } + var blocked int + if err := a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM user_blocks WHERE (user_id=? AND blocked_user_id=?) OR (user_id=? AND blocked_user_id=?))`, current(r).ID, req.UserID, req.UserID, current(r).ID).Scan(&blocked); err != nil || blocked == 1 { + fail(w, http.StatusForbidden, 30002, "当前无法与该用户聊天") + return + } + var id int64 + err := a.db.QueryRowContext(r.Context(), `SELECT conversation_id FROM im_direct_conversations WHERE user1_id=? AND user2_id=?`, first, second).Scan(&id) + if err == nil { + reply(w, map[string]any{"id": id}) + return + } + if allowStranger == 0 { + var mutualFollow int + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS( + SELECT 1 FROM user_follows mine JOIN user_follows target + ON target.user_id=mine.target_user_id AND target.target_user_id=mine.user_id + WHERE mine.user_id=? AND mine.target_user_id=?)`, current(r).ID, req.UserID).Scan(&mutualFollow) + if mutualFollow != 1 { + fail(w, http.StatusForbidden, 30002, "对方仅允许互相关注的人发起私信") + return + } + } + tx, beginErr := a.db.BeginTx(r.Context(), nil) + if beginErr != nil { + fail(w, http.StatusInternalServerError, 50001, "创建会话失败") + return + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(r.Context(), `INSERT INTO im_conversations(conversation_type)VALUES(1)`) + if err != nil { + _ = tx.Rollback() + // Concurrent requests may have created the same unique direct pair. + if a.db.QueryRowContext(r.Context(), `SELECT conversation_id FROM im_direct_conversations WHERE user1_id=? AND user2_id=?`, first, second).Scan(&id) == nil { + reply(w, map[string]any{"id": id}) + return + } + fail(w, 500, 50001, "创建会话失败") + return + } + id, _ = result.LastInsertId() + _, err = tx.ExecContext(r.Context(), `INSERT INTO im_direct_conversations(conversation_id,user1_id,user2_id)VALUES(?,?,?)`, id, first, second) + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT INTO im_conversation_members(conversation_id,user_id)VALUES(?,?),(?,?)`, id, first, id, second) + } + if err != nil { + _ = tx.Rollback() + fail(w, 500, 50001, "创建会话失败") + return + } + _ = tx.Commit() + reply(w, map[string]any{"id": id}) +} + +func (a *App) conversations(w http.ResponseWriter, r *http.Request) { + who := current(r) + rows, err := a.db.QueryContext(r.Context(), `SELECT c.id,c.last_seq,c.last_message_at,m.read_seq,m.pinned,m.muted, + other.user_id,p.nickname,p.avatar_url,p.is_vip,p.last_active_at,privacy.online_visible,COALESCE(CAST(msg.body AS CHAR CHARACTER SET utf8mb4),''),msg.recalled_at,msg.admin_removed_at + FROM im_conversation_members m JOIN im_conversations c ON c.id=m.conversation_id + JOIN im_conversation_members other ON other.conversation_id=c.id AND other.user_id<>m.user_id + JOIN user_profiles p ON p.user_id=other.user_id JOIN user_privacy_settings privacy ON privacy.user_id=other.user_id LEFT JOIN im_messages msg ON msg.id=c.last_message_id + WHERE m.user_id=? AND m.status=1 ORDER BY m.pinned DESC,c.last_message_at DESC`, who.ID) + if err != nil { + fail(w, 500, 50001, err.Error()) + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, lastSeq, readSeq, otherID int64 + var lastAt sql.NullTime + var pinned, muted, vip int + var nick, avatar, body string + var active sql.NullTime + var recalledAt, adminRemovedAt sql.NullTime + var onlineVisible int + _ = rows.Scan(&id, &lastSeq, &lastAt, &readSeq, &pinned, &muted, &otherID, &nick, &avatar, &vip, &active, &onlineVisible, &body, &recalledAt, &adminRemovedAt) + preview := "开始聊天吧" + var content map[string]any + if recalledAt.Valid || adminRemovedAt.Valid { + preview = "消息已撤回" + } else if json.Unmarshal([]byte(body), &content) == nil { + if text, ok := content["text"].(string); ok { + preview = text + } + } + items = append(items, map[string]any{"id": id, "lastSeq": lastSeq, "unread": max64(lastSeq-readSeq, 0), "lastMessageAt": lastAt, "pinned": pinned == 1, "muted": muted == 1, "lastMessage": preview, "user": map[string]any{"id": otherID, "nickname": nick, "avatar": avatar, "vip": vip == 1, "online": onlineVisible == 1 && active.Valid && time.Since(active.Time) < 15*time.Minute}}) + } + reply(w, map[string]any{"items": items, "total": len(items)}) +} + +func (a *App) messages(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "invalid conversation") + return + } + if !a.isMember(r, id, current(r).ID) { + fail(w, 403, 30002, "不是会话成员") + return + } + beforeSeq, _ := strconv.ParseInt(r.URL.Query().Get("beforeSeq"), 10, 64) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 || limit > 100 { + limit = 50 + } + query := `SELECT id,conversation_id,seq,sender_id,client_msg_id,message_type,body,recalled_at,admin_removed_at,created_at FROM im_messages WHERE conversation_id=?` + args := []any{id} + if beforeSeq > 0 { + query += ` AND seq limit + if hasMore { + items = items[:limit] + } + sort.Slice(items, func(i, j int) bool { return items[i].Seq < items[j].Seq }) + if len(items) > 0 { + last := items[len(items)-1].Seq + _, _ = a.db.ExecContext(r.Context(), `UPDATE im_conversation_members SET read_seq=GREATEST(read_seq,?) WHERE conversation_id=? AND user_id=?`, last, id, current(r).ID) + } + nextBeforeSeq := int64(0) + if len(items) > 0 { + nextBeforeSeq = items[0].Seq + } + reply(w, map[string]any{"items": items, "hasMore": hasMore, "nextBeforeSeq": nextBeforeSeq}) +} + +func (a *App) sendMessageHTTP(w http.ResponseWriter, r *http.Request) { + conversationID, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "invalid conversation") + return + } + var req struct { + ClientMsgID string `json:"clientMsgId"` + Type int `json:"type"` + Content any `json:"content"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "消息格式错误") + return + } + item, members, err := a.persistMessage(r, conversationID, current(r).ID, req.ClientMsgID, req.Type, req.Content) + if err != nil { + var limitErr *dailyActiveChatLimitError + if errors.As(err, &limitErr) { + fail(w, http.StatusTooManyRequests, 30005, limitErr.Error()) + return + } + fail(w, 400, 30004, err.Error()) + return + } + a.hub.broadcast(members, map[string]any{"command": "MESSAGE_PUSH", "data": item}) + reply(w, item) +} + +func (a *App) recallMessage(w http.ResponseWriter, r *http.Request) { + messageID, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "消息编号无效") + return + } + window, _ := strconv.Atoi(a.configPlain(r.Context(), "im.recall_seconds", "120")) + if window < 1 || window > 86400 { + window = 120 + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "撤回失败") + return + } + defer func() { _ = tx.Rollback() }() + var conversationID, seq int64 + var senderID int64 + var created time.Time + var recalled sql.NullTime + if err = tx.QueryRowContext(r.Context(), `SELECT conversation_id,seq,sender_id,created_at,recalled_at FROM im_messages WHERE id=? FOR UPDATE`, messageID).Scan(&conversationID, &seq, &senderID, &created, &recalled); err != nil { + fail(w, 404, 30001, "消息不存在") + return + } + if senderID != current(r).ID { + fail(w, 403, 30002, "只能撤回自己发送的消息") + return + } + if recalled.Valid { + reply(w, map[string]bool{"success": true}) + return + } + if time.Since(created) > time.Duration(window)*time.Second { + fail(w, 409, 30004, "已超过消息撤回时限") + return + } + _, err = tx.ExecContext(r.Context(), `UPDATE im_messages SET recalled_at=NOW(3) WHERE id=?`, messageID) + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "撤回失败") + return + } + rows, _ := a.db.QueryContext(r.Context(), `SELECT user_id FROM im_conversation_members WHERE conversation_id=? AND status=1`, conversationID) + members := []int64{} + if rows != nil { + defer rows.Close() + for rows.Next() { + var id int64 + _ = rows.Scan(&id) + members = append(members, id) + } + } + a.hub.broadcast(members, map[string]any{"command": "MESSAGE_RECALLED", "data": map[string]any{"id": messageID, "conversationId": conversationID, "seq": seq}}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) persistMessage(r *http.Request, conversationID, senderID int64, clientMsgID string, messageType int, content any) (messageView, []int64, error) { + item := messageView{} + clientMsgID, content, body, err := a.validateMessagePayload(clientMsgID, messageType, content) + if err != nil { + return item, nil, err + } + if messageType == 0 { + messageType = 1 + } + if messageType == 2 || messageType == 3 { + contentMap, _ := content.(map[string]any) + mediaURL, _ := contentMap["url"].(string) + expectedType := "image" + if messageType == 3 { + expectedType = "audio" + } + var owned int + if queryErr := a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM media_assets WHERE owner_user_id=? AND public_url=? AND media_type=? AND status=1 AND moderation_status=1)`, senderID, mediaURL, expectedType).Scan(&owned); queryErr != nil || owned != 1 { + return item, nil, fmt.Errorf("消息媒体必须由当前账号上传") + } + } + if !a.allowRequest(r.Context(), "message_send", fmt.Sprintf("%d", senderID), 120, time.Minute) { + return item, nil, fmt.Errorf("消息发送过于频繁,请稍后再试") + } + if a.isSanctionActive(r.Context(), senderID, "MUTE") { + return item, nil, fmt.Errorf("账号处于禁言期,暂时无法发送消息") + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + return item, nil, err + } + defer func() { _ = tx.Rollback() }() + var lastSeq int64 + if err = tx.QueryRowContext(r.Context(), `SELECT last_seq FROM im_conversations WHERE id=? AND status=1 FOR UPDATE`, conversationID).Scan(&lastSeq); err != nil { + return item, nil, fmt.Errorf("会话不存在") + } + var memberCount int + if err = tx.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM im_conversation_members WHERE conversation_id=? AND user_id=? AND status=1`, conversationID, senderID).Scan(&memberCount); err != nil || memberCount == 0 { + return item, nil, fmt.Errorf("不是会话成员") + } + var blocked int + if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS( + SELECT 1 FROM im_direct_conversations d JOIN user_blocks b + ON (b.user_id=d.user1_id AND b.blocked_user_id=d.user2_id) OR (b.user_id=d.user2_id AND b.blocked_user_id=d.user1_id) + WHERE d.conversation_id=?)`, conversationID).Scan(&blocked); err != nil { + return item, nil, err + } + if blocked == 1 { + return item, nil, fmt.Errorf("当前无法向该用户发送消息") + } + if err = a.reserveDailyActiveChat(r.Context(), tx, conversationID, senderID); err != nil { + return item, nil, err + } + seq := lastSeq + 1 + result, err := tx.ExecContext(r.Context(), `INSERT INTO im_messages(conversation_id,seq,sender_id,client_msg_id,message_type,body)VALUES(?,?,?,?,?,?)`, conversationID, seq, senderID, clientMsgID, messageType, body) + if err != nil { + var existingID, existingSeq int64 + existingErr := tx.QueryRowContext(r.Context(), `SELECT id,seq FROM im_messages WHERE sender_id=? AND client_msg_id=?`, senderID, clientMsgID).Scan(&existingID, &existingSeq) + if existingErr == nil { + _ = tx.Rollback() + return a.loadMessage(r, existingID), nil, nil + } + return item, nil, err + } + messageID, _ := result.LastInsertId() + _, err = tx.ExecContext(r.Context(), `UPDATE im_conversations SET last_seq=?,last_message_id=?,last_message_at=NOW(3) WHERE id=?`, seq, messageID, conversationID) + if err != nil { + return item, nil, err + } + if _, err = tx.ExecContext(r.Context(), `UPDATE im_conversation_members SET delivered_seq=GREATEST(delivered_seq,?),updated_at=NOW(3) WHERE conversation_id=?`, seq, conversationID); err != nil { + return item, nil, err + } + memberRows, err := tx.QueryContext(r.Context(), `SELECT user_id FROM im_conversation_members WHERE conversation_id=? AND status=1`, conversationID) + if err != nil { + return item, nil, err + } + members := []int64{} + for memberRows.Next() { + var userID int64 + if err = memberRows.Scan(&userID); err != nil { + _ = memberRows.Close() + return item, nil, err + } + members = append(members, userID) + } + if err = memberRows.Err(); err != nil { + _ = memberRows.Close() + return item, nil, err + } + if err = memberRows.Close(); err != nil { + return item, nil, err + } + sort.Slice(members, func(i, j int) bool { return members[i] < members[j] }) + for _, userID := range members { + var lockedUserID int64 + if err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE id=? FOR UPDATE`, userID).Scan(&lockedUserID); err != nil { + return item, nil, err + } + } + for _, userID := range members { + var next int64 + if err = tx.QueryRowContext(r.Context(), `SELECT COALESCE(MAX(event_seq),0)+1 FROM im_user_sync_events WHERE user_id=?`, userID).Scan(&next); err != nil { + return item, nil, err + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO im_user_sync_events(user_id,event_seq,event_type,conversation_id,message_seq,event_data)VALUES(?, ?,12,?,?,?)`, userID, next, conversationID, seq, body); err != nil { + return item, nil, err + } + } + if err = tx.Commit(); err != nil { + return item, nil, err + } + return messageView{ID: messageID, ConversationID: conversationID, Seq: seq, SenderID: senderID, ClientMsgID: clientMsgID, Type: messageType, Content: content, CreatedAt: time.Now()}, members, nil +} + +func (a *App) loadMessage(r *http.Request, id int64) messageView { + var item messageView + var body []byte + var recalledAt, adminRemovedAt sql.NullTime + _ = a.db.QueryRowContext(r.Context(), `SELECT id,conversation_id,seq,sender_id,client_msg_id,message_type,body,recalled_at,admin_removed_at,created_at FROM im_messages WHERE id=?`, id).Scan(&item.ID, &item.ConversationID, &item.Seq, &item.SenderID, &item.ClientMsgID, &item.Type, &body, &recalledAt, &adminRemovedAt, &item.CreatedAt) + item.Recalled = recalledAt.Valid || adminRemovedAt.Valid + if !item.Recalled { + _ = json.Unmarshal(body, &item.Content) + } + return item +} +func (a *App) isMember(r *http.Request, conversationID, userID int64) bool { + var count int + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM im_conversation_members WHERE conversation_id=? AND user_id=? AND status=1`, conversationID, userID).Scan(&count) + return count > 0 +} + +func (a *App) conversationSettings(w http.ResponseWriter, r *http.Request) { + id, pathErr := pathID(r) + if pathErr != nil { + fail(w, http.StatusBadRequest, 20001, "会话 ID 无效") + return + } + var req struct { + Pinned *bool `json:"pinned"` + Muted *bool `json:"muted"` + ReadSeq int64 `json:"readSeq"` + } + if decode(r, &req) != nil || req.ReadSeq < 0 { + fail(w, 400, 20001, "invalid settings") + return + } + pinned, muted := -1, -1 + if req.Pinned != nil { + pinned = btoi(*req.Pinned) + } + if req.Muted != nil { + muted = btoi(*req.Muted) + } + var lastSeq int64 + if err := a.db.QueryRowContext(r.Context(), `SELECT c.last_seq FROM im_conversations c JOIN im_conversation_members m ON m.conversation_id=c.id WHERE c.id=? AND m.user_id=? AND m.status=1`, id, current(r).ID).Scan(&lastSeq); err != nil { + fail(w, http.StatusForbidden, 30002, "不是会话成员") + return + } + readSeq := req.ReadSeq + if readSeq > lastSeq { + readSeq = lastSeq + } + _, err := a.db.ExecContext(r.Context(), `UPDATE im_conversation_members SET pinned=IF(?>=0,?,pinned),muted=IF(?>=0,?,muted),read_seq=GREATEST(read_seq,?) WHERE conversation_id=? AND user_id=?`, pinned, pinned, muted, muted, readSeq, id, current(r).ID) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) websocket(w http.ResponseWriter, r *http.Request) { + if !a.originAllowed(r.Header.Get("Origin")) { + fail(w, http.StatusForbidden, 10006, "WebSocket 来源不允许") + return + } + selectedProtocol := "" + raw := "" + for _, protocol := range websocket.Subprotocols(r) { + if strings.HasPrefix(protocol, "xingyu.jwt.") { + selectedProtocol = protocol + raw = strings.TrimPrefix(protocol, "xingyu.jwt.") + break + } + } + // Query-token compatibility is development-only because URLs may be written to proxy logs. + if raw == "" && a.config.Environment != "production" { + raw = r.URL.Query().Get("token") + } + who, err := a.parseToken(raw) + if err != nil || who.Role != "user" { + fail(w, 401, 10001, "invalid token") + return + } + var userStatus int + statusErr := a.db.QueryRowContext(r.Context(), `SELECT status FROM users WHERE id=? AND deleted_at IS NULL`, who.ID).Scan(&userStatus) + if statusErr == nil { + userStatus = a.normalizeUserStatus(r.Context(), who.ID, userStatus) + } + if statusErr != nil || userStatus != 1 { + fail(w, 403, 10006, "账号已被冻结或封禁") + return + } + var tokenVersion int + _ = a.db.QueryRowContext(r.Context(), `SELECT token_version FROM user_security_controls WHERE user_id=?`, who.ID).Scan(&tokenVersion) + if who.Version != tokenVersion { + fail(w, 401, 10001, "登录状态已失效") + return + } + upgrader := websocket.Upgrader{CheckOrigin: func(request *http.Request) bool { + return a.originAllowed(request.Header.Get("Origin")) + }} + if selectedProtocol != "" { + upgrader.Subprotocols = []string{selectedProtocol} + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + client := &wsClient{userID: who.ID, conn: conn} + conn.SetReadLimit(64 << 10) + _ = conn.SetReadDeadline(time.Now().Add(75 * time.Second)) + a.hub.add(client) + defer func() { a.hub.remove(client); _ = conn.Close() }() + _ = client.writeJSON(map[string]any{"command": "AUTH_ACK", "data": map[string]any{"heartbeatSeconds": 25, "serverTime": time.Now().UnixMilli()}}) + for { + var frame struct { + Command string `json:"command"` + ConversationID int64 `json:"conversationId"` + ClientMsgID string `json:"clientMsgId"` + Type int `json:"type"` + Content any `json:"content"` + ReadSeq int64 `json:"readSeq"` + } + if conn.ReadJSON(&frame) != nil { + return + } + switch frame.Command { + case "PING": + _ = conn.SetReadDeadline(time.Now().Add(75 * time.Second)) + _ = client.writeJSON(map[string]any{"command": "PONG", "timestamp": time.Now().UnixMilli()}) + case "SEND_MESSAGE": + item, members, persistErr := a.persistMessage(r, frame.ConversationID, who.ID, frame.ClientMsgID, frame.Type, frame.Content) + if persistErr != nil { + _ = client.writeJSON(map[string]any{"command": "ERROR", "message": persistErr.Error()}) + continue + } + a.hub.broadcast(members, map[string]any{"command": "MESSAGE_PUSH", "data": item}) + case "READ": + if frame.ReadSeq < 0 { + _ = client.writeJSON(map[string]any{"command": "ERROR", "message": "已读序号无效"}) + continue + } + var lastSeq int64 + if a.db.QueryRowContext(r.Context(), `SELECT c.last_seq FROM im_conversations c JOIN im_conversation_members m ON m.conversation_id=c.id WHERE c.id=? AND m.user_id=? AND m.status=1`, frame.ConversationID, who.ID).Scan(&lastSeq) != nil { + _ = client.writeJSON(map[string]any{"command": "ERROR", "message": "不是会话成员"}) + continue + } + if frame.ReadSeq > lastSeq { + frame.ReadSeq = lastSeq + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE im_conversation_members SET read_seq=GREATEST(read_seq,?) WHERE conversation_id=? AND user_id=?`, frame.ReadSeq, frame.ConversationID, who.ID) + a.hub.broadcast([]int64{who.ID}, map[string]any{"command": "READ_ACK", "data": frame}) + } + } +} + +func max64(a, b int64) int64 { + if a > b { + return a + } + return b +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/im_validation_test.go b/im/.deploy/application-20260831-120612/backend/internal/app/im_validation_test.go new file mode 100644 index 0000000..0561ad3 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/im_validation_test.go @@ -0,0 +1,39 @@ +package app + +import ( + "strings" + "testing" +) + +func TestValidateMessagePayload(t *testing.T) { + application := &App{config: Config{Environment: "development"}} + clientID, content, body, err := application.validateMessagePayload("client-1", 1, map[string]any{"text": " 你好 "}) + if err != nil { + t.Fatalf("expected valid text message: %v", err) + } + if clientID != "client-1" || content.(map[string]any)["text"] != "你好" || len(body) == 0 { + t.Fatalf("message was not normalized: %#v %#v", clientID, content) + } + if _, _, _, err = application.validateMessagePayload("bad id", 1, map[string]any{"text": "hello"}); err == nil { + t.Fatal("client message IDs containing spaces must be rejected") + } + if _, _, _, err = application.validateMessagePayload("client-2", 1, map[string]any{"text": strings.Repeat("好", maxMessageTextRunes+1)}); err == nil { + t.Fatal("oversized text messages must be rejected") + } + if _, _, _, err = application.validateMessagePayload("client-3", 3, map[string]any{"duration": 61.0, "url": "/uploads/voice.mp3"}); err == nil { + t.Fatal("voice messages longer than 60 seconds must be rejected") + } +} + +func TestProductionMessageMediaRequiresHTTPS(t *testing.T) { + application := &App{config: Config{Environment: "production"}} + if _, _, _, err := application.validateMessagePayload("client-1", 2, map[string]any{"url": "http://cdn.example.com/a.jpg"}); err == nil { + t.Fatal("production media URLs must use HTTPS") + } + if _, _, _, err := application.validateMessagePayload("client-2", 2, map[string]any{"url": "https://cdn.example.com/a.jpg"}); err != nil { + t.Fatalf("valid HTTPS media URL rejected: %v", err) + } + if validMessageMediaURL("/uploads/../secret", true) { + t.Fatal("local media paths must not allow traversal") + } +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/integration.go b/im/.deploy/application-20260831-120612/backend/internal/app/integration.go new file mode 100644 index 0000000..257876d --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/integration.go @@ -0,0 +1,602 @@ +package app + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +const maskedSecret = "••••••••" + +type integrationFieldSpec struct { + Key string + Label string + Input string + Required bool + Options []string + Providers []string + Description string +} + +type integrationFieldView struct { + Key string `json:"key"` + Label string `json:"label"` + Value string `json:"value"` + Input string `json:"input"` + Secret bool `json:"secret"` + HasValue bool `json:"hasValue"` + Required bool `json:"required"` + Options []string `json:"options,omitempty"` + Providers []string `json:"providers,omitempty"` + Description string `json:"description"` +} + +var integrationSpecs = map[string][]integrationFieldSpec{ + "oauth": { + {Key: "oauth.admin.frontend_callback_url", Label: "管理端登录结果页", Input: "text", Description: "管理端启用第三方登录时必填;生产环境必须使用 HTTPS"}, + {Key: "oauth.user.frontend_callback_url", Label: "客户端 H5 登录结果页", Input: "text", Description: "H5 启用第三方登录时必填;生产环境必须使用 HTTPS"}, + {Key: "oauth.app.frontend_callback_url", Label: "App GitHub 登录结果地址", Input: "text", Providers: []string{"github"}, Description: "固定为 xingyuim://oauth/callback;Android/iOS 打包须注册 xingyuim URL Scheme,GitHub 平台仍填写后端 HTTPS 回调地址"}, + {Key: "oauth.app.wechat.enabled", Label: "App 启用微信登录", Input: "boolean", Required: true, Providers: []string{"wechat"}, Description: "后端控制 App 原生微信登录;还需在 manifest 中配置微信 SDK 并重新打包"}, + {Key: "oauth.app.qq.enabled", Label: "App 启用 QQ 登录", Input: "boolean", Required: true, Providers: []string{"qq"}, Description: "后端控制 App 原生 QQ 登录;还需在 manifest 中配置 QQ SDK 并重新打包"}, + {Key: "oauth.app.github.enabled", Label: "App 启用 GitHub 登录", Input: "boolean", Required: true, Providers: []string{"github"}, Description: "系统浏览器授权后返回 App;复用下方 GitHub OAuth 应用参数"}, + {Key: "oauth.app.google.enabled", Label: "App 启用 Google 登录", Input: "boolean", Required: true, Providers: []string{"google"}, Description: "后端控制 App 原生 Google 登录;需配置 Google SDK 和允许的客户端 ID"}, + {Key: "oauth.app.wechat.client_id", Label: "App 微信 AppID", Input: "text", Providers: []string{"wechat"}, Description: "微信开放平台移动应用 AppID,需与 App 打包配置一致;独立于网站应用"}, + {Key: "oauth.app.wechat.client_secret", Label: "App 微信 AppSecret", Input: "secret", Providers: []string{"wechat"}, Description: "移动应用密钥,AES-GCM 加密保存;不得写入 App 包"}, + {Key: "oauth.app.qq.client_id", Label: "App QQ AppID", Input: "text", Providers: []string{"qq"}, Description: "QQ 互联移动应用 AppID,需与 App 打包配置一致"}, + {Key: "oauth.app.google.client_ids", Label: "App Google Client ID 白名单", Input: "text", Providers: []string{"google"}, Description: "Android/iOS OAuth 客户端 ID,以英文逗号分隔;后端验证令牌所属应用"}, + + {Key: "oauth.wechat.enabled", Label: "管理端启用微信登录", Input: "boolean", Required: true, Providers: []string{"wechat"}, Description: "开启后且必填参数完整时,管理端登录页显示微信入口"}, + {Key: "oauth.user.wechat.enabled", Label: "H5 启用微信登录", Input: "boolean", Required: true, Providers: []string{"wechat"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示微信入口"}, + {Key: "oauth.wechat.client_id", Label: "微信 AppID", Input: "text", Providers: []string{"wechat"}, Description: "微信开放平台网站应用 AppID"}, + {Key: "oauth.wechat.client_secret", Label: "微信 AppSecret", Input: "secret", Providers: []string{"wechat"}, Description: "AES-GCM 加密保存"}, + {Key: "oauth.wechat.authorization_url", Label: "微信授权地址", Input: "text", Providers: []string{"wechat"}, Description: "默认使用微信开放平台 qrconnect 地址"}, + {Key: "oauth.wechat.token_url", Label: "微信令牌地址", Input: "text", Providers: []string{"wechat"}, Description: "授权码换取 access_token 的地址"}, + {Key: "oauth.wechat.userinfo_url", Label: "微信用户信息地址", Input: "text", Providers: []string{"wechat"}, Description: "获取登录用户 OpenID 与资料的地址"}, + {Key: "oauth.wechat.scope", Label: "微信授权范围", Input: "text", Providers: []string{"wechat"}, Description: "网站扫码登录通常为 snsapi_login"}, + {Key: "oauth.wechat.redirect_uri", Label: "微信回调地址", Input: "text", Providers: []string{"wechat"}, Description: "必须与微信开放平台登记值完全一致"}, + + {Key: "oauth.qq.enabled", Label: "管理端启用 QQ 登录", Input: "boolean", Required: true, Providers: []string{"qq"}, Description: "开启后且必填参数完整时,管理端登录页显示 QQ 入口"}, + {Key: "oauth.user.qq.enabled", Label: "H5 启用 QQ 登录", Input: "boolean", Required: true, Providers: []string{"qq"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示 QQ 入口"}, + {Key: "oauth.qq.client_id", Label: "QQ AppID", Input: "text", Providers: []string{"qq"}, Description: "QQ 互联应用 AppID"}, + {Key: "oauth.qq.client_secret", Label: "QQ AppKey", Input: "secret", Providers: []string{"qq"}, Description: "AES-GCM 加密保存"}, + {Key: "oauth.qq.authorization_url", Label: "QQ 授权地址", Input: "text", Providers: []string{"qq"}, Description: "QQ OAuth 2.0 authorize 地址"}, + {Key: "oauth.qq.token_url", Label: "QQ 令牌地址", Input: "text", Providers: []string{"qq"}, Description: "授权码换取 access_token 的地址"}, + {Key: "oauth.qq.openid_url", Label: "QQ OpenID 地址", Input: "text", Providers: []string{"qq"}, Description: "使用 access_token 获取 QQ OpenID"}, + {Key: "oauth.qq.userinfo_url", Label: "QQ 用户信息地址", Input: "text", Providers: []string{"qq"}, Description: "获取昵称和头像"}, + {Key: "oauth.qq.scope", Label: "QQ 授权范围", Input: "text", Providers: []string{"qq"}, Description: "默认 get_user_info"}, + {Key: "oauth.qq.redirect_uri", Label: "QQ 回调地址", Input: "text", Providers: []string{"qq"}, Description: "必须与 QQ 互联登记值完全一致"}, + + {Key: "oauth.github.enabled", Label: "管理端启用 GitHub 登录", Input: "boolean", Required: true, Providers: []string{"github"}, Description: "开启后且必填参数完整时,管理端登录页显示 GitHub 入口"}, + {Key: "oauth.user.github.enabled", Label: "H5 启用 GitHub 登录", Input: "boolean", Required: true, Providers: []string{"github"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示 GitHub 入口"}, + {Key: "oauth.github.client_id", Label: "GitHub Client ID", Input: "text", Providers: []string{"github"}, Description: "GitHub OAuth App Client ID"}, + {Key: "oauth.github.client_secret", Label: "GitHub Client Secret", Input: "secret", Providers: []string{"github"}, Description: "AES-GCM 加密保存"}, + {Key: "oauth.github.authorization_url", Label: "GitHub 授权地址", Input: "text", Providers: []string{"github"}, Description: "GitHub OAuth authorize 地址"}, + {Key: "oauth.github.token_url", Label: "GitHub 令牌地址", Input: "text", Providers: []string{"github"}, Description: "授权码换取 access_token 的地址"}, + {Key: "oauth.github.userinfo_url", Label: "GitHub 用户信息地址", Input: "text", Providers: []string{"github"}, Description: "默认使用 /user 接口"}, + {Key: "oauth.github.scope", Label: "GitHub 授权范围", Input: "text", Providers: []string{"github"}, Description: "建议仅 read:user user:email"}, + {Key: "oauth.github.redirect_uri", Label: "GitHub 回调地址", Input: "text", Providers: []string{"github"}, Description: "必须与 OAuth App 的 callback URL 完全一致"}, + + {Key: "oauth.google.enabled", Label: "管理端启用 Google 登录", Input: "boolean", Required: true, Providers: []string{"google"}, Description: "开启后且必填参数完整时,管理端登录页显示 Google 入口"}, + {Key: "oauth.user.google.enabled", Label: "H5 启用 Google 登录", Input: "boolean", Required: true, Providers: []string{"google"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示 Google 入口"}, + {Key: "oauth.google.client_id", Label: "Google Client ID", Input: "text", Providers: []string{"google"}, Description: "Google OAuth 2.0 Client ID"}, + {Key: "oauth.google.client_secret", Label: "Google Client Secret", Input: "secret", Providers: []string{"google"}, Description: "AES-GCM 加密保存"}, + {Key: "oauth.google.authorization_url", Label: "Google 授权地址", Input: "text", Providers: []string{"google"}, Description: "Google OAuth authorization endpoint"}, + {Key: "oauth.google.token_url", Label: "Google 令牌地址", Input: "text", Providers: []string{"google"}, Description: "Google OAuth token endpoint"}, + {Key: "oauth.google.userinfo_url", Label: "Google UserInfo 地址", Input: "text", Providers: []string{"google"}, Description: "OpenID Connect UserInfo endpoint"}, + {Key: "oauth.google.scope", Label: "Google 授权范围", Input: "text", Providers: []string{"google"}, Description: "至少包含 openid profile email"}, + {Key: "oauth.google.redirect_uri", Label: "Google 回调地址", Input: "text", Providers: []string{"google"}, Description: "必须与 Google Cloud Console 登记值完全一致"}, + }, + "storage": { + {Key: "storage.provider", Label: "当前存储厂商", Input: "select", Required: true, Options: []string{"local", "aliyun_oss", "tencent_cos", "qiniu", "huawei_obs", "huawei_flexus"}, Description: "保存后所有新上传文件立即切换到所选存储;历史文件地址不受影响"}, + {Key: "storage.object_prefix", Label: "云端对象前缀", Input: "text", Required: true, Description: "仅允许字母、数字、斜杠、下划线和短横线,例如 media"}, + + {Key: "storage.local.directory", Label: "本地存储目录", Input: "text", Required: true, Providers: []string{"local"}, Description: "相对路径基于后端运行目录;禁止配置为磁盘根目录"}, + {Key: "storage.local.public_base_url", Label: "本地公开访问地址", Input: "text", Providers: []string{"local"}, Description: "可选,例如 https://api.example.com/uploads;留空时根据当前请求生成"}, + + {Key: "storage.aliyun_oss.endpoint", Label: "阿里云 OSS Endpoint", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "例如 https://oss-cn-hangzhou.aliyuncs.com"}, + {Key: "storage.aliyun_oss.region", Label: "阿里云 OSS Region", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "例如 cn-hangzhou"}, + {Key: "storage.aliyun_oss.bucket", Label: "阿里云 OSS Bucket", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "需要 oss:PutObject 权限"}, + {Key: "storage.aliyun_oss.access_key_id", Label: "阿里云 AccessKey ID", Input: "secret", Required: true, Providers: []string{"aliyun_oss"}, Description: "建议使用最小权限 RAM 用户,AES-GCM 加密保存"}, + {Key: "storage.aliyun_oss.access_key_secret", Label: "阿里云 AccessKey Secret", Input: "secret", Required: true, Providers: []string{"aliyun_oss"}, Description: "AES-GCM 加密保存"}, + {Key: "storage.aliyun_oss.public_base_url", Label: "阿里云文件访问域名", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "Bucket 公网域名或已配置的 CDN/自定义域名,必须使用 HTTPS"}, + + {Key: "storage.tencent_cos.endpoint", Label: "腾讯云 COS Bucket URL", Input: "text", Required: true, Providers: []string{"tencent_cos"}, Description: "例如 https://bucket-appid.cos.ap-guangzhou.myqcloud.com"}, + {Key: "storage.tencent_cos.bucket", Label: "腾讯云 COS Bucket", Input: "text", Required: true, Providers: []string{"tencent_cos"}, Description: "完整名称需包含 APPID,需要 cos:PutObject 权限"}, + {Key: "storage.tencent_cos.secret_id", Label: "腾讯云 SecretId", Input: "secret", Required: true, Providers: []string{"tencent_cos"}, Description: "建议使用最小权限 CAM 子账号密钥,AES-GCM 加密保存"}, + {Key: "storage.tencent_cos.secret_key", Label: "腾讯云 SecretKey", Input: "secret", Required: true, Providers: []string{"tencent_cos"}, Description: "AES-GCM 加密保存"}, + {Key: "storage.tencent_cos.public_base_url", Label: "腾讯云文件访问域名", Input: "text", Required: true, Providers: []string{"tencent_cos"}, Description: "Bucket 公网域名或 CDN 域名,必须使用 HTTPS"}, + + {Key: "storage.qiniu.bucket", Label: "七牛云空间名称", Input: "text", Required: true, Providers: []string{"qiniu"}, Description: "Kodo Bucket 名称,SDK 自动发现上传区域"}, + {Key: "storage.qiniu.access_key", Label: "七牛云 AccessKey", Input: "secret", Required: true, Providers: []string{"qiniu"}, Description: "建议使用仅具备目标空间上传权限的密钥,AES-GCM 加密保存"}, + {Key: "storage.qiniu.secret_key", Label: "七牛云 SecretKey", Input: "secret", Required: true, Providers: []string{"qiniu"}, Description: "AES-GCM 加密保存"}, + {Key: "storage.qiniu.public_base_url", Label: "七牛云文件访问域名", Input: "text", Required: true, Providers: []string{"qiniu"}, Description: "空间绑定域名或 CDN 域名,必须使用 HTTPS"}, + + {Key: "storage.huawei_obs.endpoint", Label: "华为云 OBS Endpoint", Input: "text", Required: true, Providers: []string{"huawei_obs"}, Description: "例如 https://obs.cn-north-4.myhuaweicloud.com"}, + {Key: "storage.huawei_obs.bucket", Label: "华为云 OBS Bucket", Input: "text", Required: true, Providers: []string{"huawei_obs"}, Description: "需要 obs:object:PutObject 权限"}, + {Key: "storage.huawei_obs.access_key", Label: "华为云 OBS Access Key", Input: "secret", Required: true, Providers: []string{"huawei_obs"}, Description: "IAM 用户 AK,AES-GCM 加密保存"}, + {Key: "storage.huawei_obs.secret_key", Label: "华为云 OBS Secret Key", Input: "secret", Required: true, Providers: []string{"huawei_obs"}, Description: "IAM 用户 SK,AES-GCM 加密保存"}, + {Key: "storage.huawei_obs.public_base_url", Label: "华为云 OBS 文件访问域名", Input: "text", Required: true, Providers: []string{"huawei_obs"}, Description: "Bucket 公网域名或 CDN 域名,必须使用 HTTPS"}, + + {Key: "storage.huawei_flexus.endpoint", Label: "Flexus 对象存储 Endpoint", Input: "text", Required: true, Providers: []string{"huawei_flexus"}, Description: "Flexus 对象存储控制台提供的 OBS 兼容 Endpoint"}, + {Key: "storage.huawei_flexus.bucket", Label: "Flexus Bucket", Input: "text", Required: true, Providers: []string{"huawei_flexus"}, Description: "Flexus 对象存储桶名称"}, + {Key: "storage.huawei_flexus.access_key", Label: "Flexus Access Key", Input: "secret", Required: true, Providers: []string{"huawei_flexus"}, Description: "AES-GCM 加密保存"}, + {Key: "storage.huawei_flexus.secret_key", Label: "Flexus Secret Key", Input: "secret", Required: true, Providers: []string{"huawei_flexus"}, Description: "AES-GCM 加密保存"}, + {Key: "storage.huawei_flexus.public_base_url", Label: "Flexus 文件访问域名", Input: "text", Required: true, Providers: []string{"huawei_flexus"}, Description: "Bucket 公网域名或 CDN 域名,必须使用 HTTPS"}, + }, + "sms": { + {Key: "sms.enabled", Label: "启用短信服务", Input: "boolean", Required: true, Description: "关闭后将拒绝发送验证码"}, + {Key: "sms.provider", Label: "当前短信厂商", Input: "select", Required: true, Options: []string{"aliyun", "tencent", "huawei", "webhook", "debug"}, Description: "保存后新验证码立即切换到所选厂商;debug 仅限本地开发"}, + {Key: "sms.expire_seconds", Label: "有效期(秒)", Input: "number", Required: true, Description: "建议 120 至 600 秒"}, + {Key: "sms.debug_code", Label: "调试验证码", Input: "secret", Required: true, Providers: []string{"debug"}, Description: "仅 debug 模式返回给客户端"}, + + {Key: "sms.aliyun.endpoint", Label: "阿里云 API 地址", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "国内短信默认 https://dysmsapi.aliyuncs.com"}, + {Key: "sms.aliyun.access_key_id", Label: "阿里云 AccessKey ID", Input: "secret", Required: true, Providers: []string{"aliyun"}, Description: "建议使用仅授予短信发送权限的 RAM 用户"}, + {Key: "sms.aliyun.access_key_secret", Label: "阿里云 AccessKey Secret", Input: "secret", Required: true, Providers: []string{"aliyun"}, Description: "AES-GCM 加密保存"}, + {Key: "sms.aliyun.sign_name", Label: "阿里云短信签名", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "必须是审核通过的签名名称"}, + {Key: "sms.aliyun.template_register", Label: "阿里云注册模板 Code", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "注册验证码模板,例如 SMS_123456789"}, + {Key: "sms.aliyun.template_login", Label: "阿里云登录模板 Code", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "短信登录验证码模板"}, + {Key: "sms.aliyun.template_reset", Label: "阿里云重置密码模板 Code", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "找回密码验证码模板"}, + {Key: "sms.aliyun.template_params", Label: "阿里云模板变量 JSON", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "支持 {{code}} 与 {{minutes}},例如 {\"code\":\"{{code}}\"}"}, + + {Key: "sms.tencent.endpoint", Label: "腾讯云 API 地址", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "国内短信默认 https://sms.tencentcloudapi.com"}, + {Key: "sms.tencent.secret_id", Label: "腾讯云 SecretId", Input: "secret", Required: true, Providers: []string{"tencent"}, Description: "建议使用最小权限 CAM 子账号密钥"}, + {Key: "sms.tencent.secret_key", Label: "腾讯云 SecretKey", Input: "secret", Required: true, Providers: []string{"tencent"}, Description: "AES-GCM 加密保存"}, + {Key: "sms.tencent.sdk_app_id", Label: "腾讯云短信 SdkAppId", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "短信控制台应用 ID"}, + {Key: "sms.tencent.region", Label: "腾讯云地域", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "国内短信建议 ap-guangzhou"}, + {Key: "sms.tencent.sign_name", Label: "腾讯云短信签名", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "必须是审核通过的签名内容"}, + {Key: "sms.tencent.template_register", Label: "腾讯云注册模板 ID", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "注册验证码模板 ID"}, + {Key: "sms.tencent.template_login", Label: "腾讯云登录模板 ID", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "短信登录验证码模板 ID"}, + {Key: "sms.tencent.template_reset", Label: "腾讯云重置密码模板 ID", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "找回密码验证码模板 ID"}, + {Key: "sms.tencent.template_params", Label: "腾讯云模板参数 JSON", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "参数按模板变量顺序排列,例如 [\"{{code}}\"]"}, + + {Key: "sms.huawei.endpoint", Label: "华为云 APP 接入地址", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "填写控制台提供的 HTTPS 地址,包含 /sms/batchSendSms/v1"}, + {Key: "sms.huawei.app_key", Label: "华为云 Application Key", Input: "secret", Required: true, Providers: []string{"huawei"}, Description: "短信应用的 APP_Key"}, + {Key: "sms.huawei.app_secret", Label: "华为云 Application Secret", Input: "secret", Required: true, Providers: []string{"huawei"}, Description: "短信应用的 APP_Secret,AES-GCM 加密保存"}, + {Key: "sms.huawei.sender", Label: "华为云签名通道号", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "国内短信签名审核后分配的通道号"}, + {Key: "sms.huawei.signature", Label: "华为云签名名称", Input: "text", Providers: []string{"huawei"}, Description: "通用模板需要填写已审核签名;非通用模板可留空"}, + {Key: "sms.huawei.template_register", Label: "华为云注册模板 ID", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "注册验证码模板 ID"}, + {Key: "sms.huawei.template_login", Label: "华为云登录模板 ID", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "短信登录验证码模板 ID"}, + {Key: "sms.huawei.template_reset", Label: "华为云重置密码模板 ID", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "找回密码验证码模板 ID"}, + {Key: "sms.huawei.template_params", Label: "华为云模板参数 JSON", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "参数按模板变量顺序排列,例如 [\"{{code}}\"]"}, + {Key: "sms.huawei.status_callback", Label: "华为云状态回调地址", Input: "text", Providers: []string{"huawei"}, Description: "可选,接收运营商最终送达状态"}, + + {Key: "sms.sign_name", Label: "Webhook 短信签名", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "发送给自建网关的签名名称"}, + {Key: "sms.template_register", Label: "Webhook 注册模板 ID", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "注册场景模板"}, + {Key: "sms.template_login", Label: "Webhook 登录模板 ID", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "验证码登录场景模板"}, + {Key: "sms.template_reset", Label: "Webhook 找回密码模板 ID", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "重置密码场景模板"}, + {Key: "sms.webhook_url", Label: "Webhook 地址", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "接收 JSON POST;生产环境必须使用 HTTPS"}, + {Key: "sms.webhook_token", Label: "Webhook Token", Input: "secret", Providers: []string{"webhook"}, Description: "以 Bearer Token 发送,仅显示保存状态"}, + }, + "payment": { + {Key: "payment.mode", Label: "支付模式", Input: "select", Required: true, Options: []string{"sandbox", "live"}, Description: "sandbox 可直接完成本地支付闭环"}, + {Key: "payment.gateway.create_url", Label: "支付网关下单地址", Input: "text", Description: "live 模式必填,生产环境必须使用 HTTPS"}, + {Key: "payment.gateway.refund_url", Label: "支付网关退款地址", Input: "text", Description: "live 模式退款必填,生产环境必须使用 HTTPS"}, + {Key: "payment.gateway.token", Label: "支付网关访问令牌", Input: "secret", Description: "live 模式必填,以 Bearer Token 调用统一支付网关"}, + {Key: "payment.gateway.notify_secret", Label: "支付回调签名密钥", Input: "secret", Description: "live 模式必填,至少 32 位随机字符串,用于 HMAC-SHA256 验签"}, + {Key: "payment.gateway.notify_url", Label: "支付异步回调地址", Input: "text", Description: "live 模式必填,例如 https://api.example.com/api/v1/payment/notify"}, + {Key: "payment.gateway.return_url", Label: "支付完成返回地址", Input: "text", Description: "H5 支付完成后返回的客户端地址"}, + {Key: "payment.gateway.timeout_seconds", Label: "网关超时(秒)", Input: "number", Description: "建议 5 至 30 秒"}, + {Key: "payment.alipay.enabled", Label: "启用支付宝", Input: "boolean", Required: true, Description: "控制客户端支付宝入口"}, + {Key: "payment.alipay.app_id", Label: "支付宝 APPID", Input: "text", Description: "开放平台应用 APPID"}, + {Key: "payment.alipay.private_key", Label: "支付宝应用私钥", Input: "secret", Description: "敏感字段加密存储"}, + {Key: "payment.alipay.public_key", Label: "支付宝公钥", Input: "secret", Description: "用于验签"}, + {Key: "payment.alipay.notify_url", Label: "支付宝通知地址", Input: "text", Description: "必须是公网 HTTPS 地址"}, + {Key: "payment.wechat.enabled", Label: "启用微信支付", Input: "boolean", Required: true, Description: "控制客户端微信支付入口"}, + {Key: "payment.wechat.app_id", Label: "微信 AppID", Input: "text", Description: "移动应用或小程序 AppID"}, + {Key: "payment.wechat.mch_id", Label: "微信商户号", Input: "text", Description: "微信支付商户号"}, + {Key: "payment.wechat.api_v3_key", Label: "APIv3 密钥", Input: "secret", Description: "敏感字段加密存储"}, + {Key: "payment.wechat.private_key", Label: "商户私钥", Input: "secret", Description: "PEM 内容,敏感字段加密存储"}, + {Key: "payment.wechat.serial_no", Label: "证书序列号", Input: "text", Description: "商户 API 证书序列号"}, + {Key: "payment.wechat.notify_url", Label: "微信通知地址", Input: "text", Description: "必须是公网 HTTPS 地址"}, + }, +} + +func (a *App) configPlain(ctx context.Context, key, fallback string) string { + var value, valueType string + if err := a.db.QueryRowContext(ctx, `SELECT config_value,value_type FROM system_configs WHERE config_key=?`, key).Scan(&value, &valueType); err != nil { + return fallback + } + if valueType == "secret" && value != "" { + plain, err := a.decryptSecret(value) + if err != nil { + return fallback + } + return plain + } + return value +} + +func (a *App) configBool(ctx context.Context, key string, fallback bool) bool { + value := strings.ToLower(a.configPlain(ctx, key, strconv.FormatBool(fallback))) + return value == "1" || value == "true" || value == "yes" || value == "on" +} + +func (a *App) encryptSecret(plain string) (string, error) { + if plain == "" { + return "", nil + } + key := sha256.Sum256([]byte(a.integrationEncryptionKey())) + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + sealed := gcm.Seal(nonce, nonce, []byte(plain), nil) + return "enc:v1:" + base64.RawStdEncoding.EncodeToString(sealed), nil +} + +func (a *App) decryptSecret(value string) (string, error) { + if value == "" { + return "", nil + } + if !strings.HasPrefix(value, "enc:v1:") { + return value, nil + } + encoded := strings.TrimPrefix(value, "enc:v1:") + payload, err := base64.RawStdEncoding.DecodeString(encoded) + if err != nil { + return "", err + } + key := sha256.Sum256([]byte(a.integrationEncryptionKey())) + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil || len(payload) < gcm.NonceSize() { + return "", fmt.Errorf("invalid encrypted secret") + } + returnValue, err := gcm.Open(nil, payload[:gcm.NonceSize()], payload[gcm.NonceSize():], nil) + if err == nil { + return string(returnValue), nil + } + if a.config.ConfigEncryptionKey != "" { + legacyKey := sha256.Sum256([]byte(a.config.JWTSecret + ":integration-config")) + legacyBlock, legacyErr := aes.NewCipher(legacyKey[:]) + if legacyErr == nil { + legacyGCM, legacyErr := cipher.NewGCM(legacyBlock) + if legacyErr == nil && len(payload) >= legacyGCM.NonceSize() { + returnValue, legacyErr = legacyGCM.Open(nil, payload[:legacyGCM.NonceSize()], payload[legacyGCM.NonceSize():], nil) + if legacyErr == nil { + return string(returnValue), nil + } + } + } + } + return "", err +} + +func (a *App) integrationEncryptionKey() string { + if a.config.ConfigEncryptionKey != "" { + return a.config.ConfigEncryptionKey + } + // Development-only compatibility for databases created before the dedicated key existed. + return a.config.JWTSecret + ":integration-config" +} + +func integrationGroup(r *http.Request) string { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + for index, part := range parts { + if part == "integrations" && index+1 < len(parts) { + return parts[index+1] + } + } + return "" +} + +func (a *App) adminIntegration(w http.ResponseWriter, r *http.Request) { + group := integrationGroup(r) + specs, ok := integrationSpecs[group] + if !ok { + fail(w, http.StatusNotFound, 30001, "配置分组不存在") + return + } + fields := make([]integrationFieldView, 0, len(specs)) + configured := true + activeProvider := "" + if group == "sms" { + activeProvider = a.configPlain(r.Context(), "sms.provider", "debug") + configured = a.configBool(r.Context(), "sms.enabled", false) + } else if group == "storage" { + activeProvider = a.configPlain(r.Context(), "storage.provider", "local") + } else if group == "oauth" { + configured = a.oauthConfigurationReady(r.Context()) + } + for _, spec := range specs { + var value, valueType string + _ = a.db.QueryRowContext(r.Context(), `SELECT config_value,value_type FROM system_configs WHERE config_key=?`, spec.Key).Scan(&value, &valueType) + secret := valueType == "secret" || spec.Input == "secret" + hasValue := value != "" + displayValue := value + if secret { + displayValue = "" + } + applies := len(spec.Providers) == 0 || containsString(spec.Providers, activeProvider) + if group == "oauth" { + applies = false // OAuth 必填项由各渠道的启用状态独立校验。 + } + if applies && spec.Required && !hasValue { + configured = false + } + fields = append(fields, integrationFieldView{Key: spec.Key, Label: spec.Label, Value: displayValue, Input: spec.Input, Secret: secret, HasValue: hasValue, Required: spec.Required, Options: spec.Options, Providers: spec.Providers, Description: spec.Description}) + } + reply(w, map[string]any{"group": group, "configured": configured, "fields": fields, "secretMask": maskedSecret}) +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func (a *App) adminUpdateIntegration(w http.ResponseWriter, r *http.Request) { + group := integrationGroup(r) + specs, ok := integrationSpecs[group] + if !ok { + fail(w, http.StatusNotFound, 30001, "配置分组不存在") + return + } + var req struct { + Values map[string]string `json:"values"` + ClearSecrets []string `json:"clearSecrets"` + } + if decode(r, &req) != nil || req.Values == nil { + fail(w, http.StatusBadRequest, 20001, "配置格式错误") + return + } + allowed := map[string]integrationFieldSpec{} + for _, spec := range specs { + allowed[spec.Key] = spec + } + for key, value := range req.Values { + spec, exists := allowed[key] + if !exists { + fail(w, http.StatusBadRequest, 20001, "配置项不允许修改") + return + } + switch spec.Input { + case "select": + if !containsString(spec.Options, value) { + fail(w, http.StatusBadRequest, 20001, spec.Label+"选项无效") + return + } + case "boolean": + if value != "true" && value != "false" { + fail(w, http.StatusBadRequest, 20001, spec.Label+"必须为 true 或 false") + return + } + case "number": + number, parseErr := strconv.Atoi(value) + if parseErr != nil { + fail(w, http.StatusBadRequest, 20001, spec.Label+"必须是数字") + return + } + if key == "sms.expire_seconds" && (number < 60 || number > 1800) { + fail(w, http.StatusBadRequest, 20001, "短信有效期必须在 60 到 1800 秒之间") + return + } + if key == "payment.gateway.timeout_seconds" && (number < 3 || number > 30) { + fail(w, http.StatusBadRequest, 20001, "支付网关超时必须在 3 到 30 秒之间") + return + } + } + } + clearSet := map[string]bool{} + for _, key := range req.ClearSecrets { + spec, exists := allowed[key] + if !exists || spec.Input != "secret" { + fail(w, http.StatusBadRequest, 20001, "清除的配置项不是允许的密钥字段") + return + } + clearSet[key] = true + } + if group == "oauth" { + if err := a.validateAdminOAuthConfigValues(r.Context(), req.Values, clearSet); err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + defer func() { _ = tx.Rollback() }() + changed := []string{} + for key, value := range req.Values { + spec, exists := allowed[key] + if !exists { + continue + } + isSecret := spec.Input == "secret" + if isSecret && value == "" && !clearSet[key] { + continue + } + if isSecret && !clearSet[key] { + value, err = a.encryptSecret(value) + if err != nil { + fail(w, 500, 50001, "加密敏感配置失败") + return + } + } + if clearSet[key] { + value = "" + } + if _, err = tx.ExecContext(r.Context(), `UPDATE system_configs SET config_value=? WHERE config_key=?`, value, key); err != nil { + fail(w, 500, 50001, "保存失败") + return + } + changed = append(changed, key) + } + if err = tx.Commit(); err != nil { + fail(w, 500, 50001, "保存失败") + return + } + a.audit(r, "update", group+"_integration", 0, map[string]any{"changedKeys": changed}) + reply(w, map[string]any{"success": true, "changedKeys": changed}) +} + +func (a *App) adminTestIntegration(w http.ResponseWriter, r *http.Request) { + group := integrationGroup(r) + switch group { + case "sms": + if !a.configBool(r.Context(), "sms.enabled", false) { + fail(w, 400, 20001, "短信服务当前未启用") + return + } + provider := a.configPlain(r.Context(), "sms.provider", "debug") + if err := a.validateSMSProviderConfig(r.Context()); err != nil { + fail(w, 400, 20001, err.Error()) + return + } + reply(w, map[string]any{"success": true, "message": cloudSMSProviderName(provider) + "短信配置校验通过", "provider": provider}) + case "payment": + mode := a.configPlain(r.Context(), "payment.mode", "sandbox") + if a.config.Environment == "production" && mode != "live" { + fail(w, 400, 20001, "生产环境必须使用 live 支付模式") + return + } + if mode == "live" && !a.paymentGatewayConfigured(r.Context()) { + fail(w, 400, 20001, "统一支付网关配置不完整,请检查下单/退款/回调 HTTPS 地址、令牌和至少 32 位回调密钥") + return + } + channels := a.availablePaymentChannels(r.Context()) + if len(channels) == 0 { + fail(w, 400, 20001, "至少启用一个支付渠道") + return + } + if mode == "live" { + for _, channel := range channels { + if channel["configured"] != true { + fail(w, 400, 20001, fmt.Sprintf("%s 的生产参数不完整", channel["name"])) + return + } + } + } + reply(w, map[string]any{"success": true, "message": "支付配置校验通过", "mode": mode, "channels": channels}) + case "storage": + provider := a.configPlain(r.Context(), "storage.provider", "local") + if err := a.validateStorageProviderConfig(r.Context()); err != nil { + fail(w, 400, 20001, err.Error()) + return + } + reply(w, map[string]any{"success": true, "message": storageProviderName(provider) + "配置校验通过", "provider": provider}) + case "oauth": + adminProviders, err := a.enabledAdminOAuthProviders(r.Context()) + if err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + userProviders, err := a.enabledUserOAuthProviders(r.Context()) + if err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + appProviders, err := a.enabledAppOAuthProviders(r.Context()) + if err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + if len(userProviders) > 0 { + if _, err = a.userOAuthFrontendURL(r.Context()); err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + } + if len(adminProviders) > 0 { + if _, err = a.adminOAuthFrontendURL(r.Context()); err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + } + if len(adminProviders) == 0 && len(userProviders) == 0 && len(appProviders) == 0 { + reply(w, map[string]any{"success": true, "message": "当前未启用第三方登录,登录页不会显示第三方入口", "providers": []any{}}) + return + } + items := make([]map[string]string, 0, len(adminProviders)+len(userProviders)) + for _, provider := range adminProviders { + items = append(items, map[string]string{"audience": "admin", "code": provider.Code, "name": provider.Name}) + } + for _, provider := range userProviders { + items = append(items, map[string]string{"audience": "user", "code": provider.Code, "name": provider.Name}) + } + for _, provider := range appProviders { + items = append(items, map[string]string{"audience": "app", "code": provider.Code, "name": provider.Name}) + } + reply(w, map[string]any{"success": true, "message": "第三方登录配置校验通过", "providers": items}) + default: + fail(w, http.StatusNotFound, 30001, "配置分组不存在") + } +} + +func (a *App) dispatchSMS(ctx context.Context, phone, scene, code string) error { + provider := a.configPlain(ctx, "sms.provider", "debug") + if err := a.validateSMSProviderConfig(ctx); err != nil { + return err + } + switch provider { + case "debug": + return nil + case "aliyun": + _, err := a.sendAliyunSMS(ctx, phone, scene, code) + return err + case "tencent": + _, err := a.sendTencentSMS(ctx, phone, scene, code) + return err + case "huawei": + _, err := a.sendHuaweiSMS(ctx, phone, scene, code) + return err + case "webhook": + return a.sendWebhookSMS(ctx, phone, scene, code) + default: + return fmt.Errorf("不支持的短信提供商") + } +} + +func (a *App) sendWebhookSMS(ctx context.Context, phone, scene, code string) error { + endpoint := a.configPlain(ctx, "sms.webhook_url", "") + if endpoint == "" { + return fmt.Errorf("短信 Webhook 未配置") + } + templateID, err := a.smsTemplateID(ctx, "webhook", scene) + if err != nil { + return err + } + payload, _ := json.Marshal(map[string]any{"phone": phone, "scene": scene, "code": code, "signName": a.configPlain(ctx, "sms.sign_name", "星遇社交"), "templateId": templateID}) + request, _ := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + request.Header.Set("Content-Type", "application/json") + if token := a.configPlain(ctx, "sms.webhook_token", ""); token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + client := &http.Client{Timeout: 8 * time.Second} + response, err := client.Do(request) + if err != nil { + return fmt.Errorf("短信网关连接失败: %w", err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("短信网关返回 HTTP %d", response.StatusCode) + } + return nil +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/media.go b/im/.deploy/application-20260831-120612/backend/internal/app/media.go new file mode 100644 index 0000000..19d6b59 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/media.go @@ -0,0 +1,187 @@ +package app + +import ( + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +const maxUploadBytes int64 = 16 << 20 + +var mediaNamePattern = regexp.MustCompile(`^[0-9]+-[0-9]+\.(?:gif|jpe?g|png|webp|mp3|wav|amr|m4a)$`) + +func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) { + // Multipart boundaries and headers add a small amount of overhead. Keep the + // actual file limit at 16 MiB without rejecting a file that is exactly at + // that limit solely because of the multipart envelope. + r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+(1<<20)) + if err := r.ParseMultipartForm(maxUploadBytes); err != nil { + fail(w, http.StatusBadRequest, 20001, "媒体文件不得超过 16MB") + return + } + + file, fileHeader, err := r.FormFile("file") + if err != nil { + fail(w, http.StatusBadRequest, 20001, "请选择媒体文件") + return + } + defer file.Close() + + size, err := file.Seek(0, io.SeekEnd) + if err != nil || size <= 0 || size > maxUploadBytes { + fail(w, http.StatusBadRequest, 20001, "媒体文件大小无效或超过 16MB") + return + } + if _, err = file.Seek(0, io.SeekStart); err != nil { + fail(w, http.StatusBadRequest, 20001, "无法读取媒体文件") + return + } + header := make([]byte, min(size, 512)) + read, err := io.ReadFull(file, header) + if err != nil && err != io.ErrUnexpectedEOF { + fail(w, http.StatusBadRequest, 20001, "无法读取媒体文件") + return + } + header = header[:read] + extensions := map[string]string{ + "image/gif": ".gif", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "audio/mpeg": ".mp3", + "audio/mp3": ".mp3", + "audio/wav": ".wav", + "audio/x-wav": ".wav", + "audio/amr": ".amr", + "audio/mp4": ".m4a", + } + contentType := http.DetectContentType(header) + extension, ok := extensions[contentType] + if !ok { + fail(w, http.StatusBadRequest, 20001, "仅支持常见图片或语音格式") + return + } + + if _, err = file.Seek(0, io.SeekStart); err != nil { + fail(w, http.StatusBadRequest, 20001, "无法读取媒体文件") + return + } + if err = a.validateStorageProviderConfig(r.Context()); err != nil { + fail(w, http.StatusServiceUnavailable, 50001, "文件存储尚未正确配置") + return + } + + provider := a.configPlain(r.Context(), "storage.provider", "local") + name := fmt.Sprintf("%d-%d%s", current(r).ID, time.Now().UnixNano(), extension) + objectKey := name + if provider != "local" { + prefix := strings.Trim(a.configPlain(r.Context(), "storage.object_prefix", "media"), "/") + objectKey = fmt.Sprintf("%s/%s/%s", prefix, time.Now().Format("2006/01"), name) + } + mediaType := "audio" + if strings.HasPrefix(contentType, "image/") { + mediaType = "image" + } + + var bucket, publicURL string + var storage mediaObjectStorage + closeStorage := func() {} + if provider == "local" { + baseURL := strings.TrimSpace(a.configPlain(r.Context(), "storage.local.public_base_url", "")) + if baseURL == "" { + scheme := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]) + if scheme != "https" { + scheme = "http" + } + baseURL = fmt.Sprintf("%s://%s/uploads", scheme, r.Host) + } + publicURL = storagePublicURL(baseURL, objectKey) + } else { + storage, closeStorage, err = a.newMediaObjectStorage(r.Context()) + if err != nil { + log.Printf("storage provider init failed provider=%s error=%v", provider, err) + fail(w, http.StatusServiceUnavailable, 50001, "文件存储连接初始化失败") + return + } + defer closeStorage() + bucket = storage.Bucket() + publicURL = storagePublicURL(a.configPlain(r.Context(), "storage."+provider+".public_base_url", ""), objectKey) + } + if len(publicURL) > 500 || len(objectKey) > 500 { + fail(w, http.StatusServiceUnavailable, 50001, "文件存储公开地址过长") + return + } + + result, err := a.db.ExecContext(r.Context(), `INSERT INTO media_assets(owner_user_id,media_type,storage_provider,bucket,object_key,public_url,mime_type,file_size,moderation_status,status) VALUES(?,?,?,?,?,'',?,?,1,0)`, current(r).ID, mediaType, provider, bucket, objectKey, contentType, size) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建媒体记录失败") + return + } + mediaID, _ := result.LastInsertId() + cleanupRecord := func() { + _, _ = a.db.ExecContext(r.Context(), `DELETE FROM media_assets WHERE id=? AND status=0`, mediaID) + } + + if provider == "local" { + directory := a.configPlain(r.Context(), "storage.local.directory", a.config.MediaDir) + if err = os.MkdirAll(directory, 0o755); err == nil { + target := filepath.Join(directory, filepath.Base(objectKey)) + var destination *os.File + destination, err = os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err == nil { + _, err = io.Copy(destination, file) + closeErr := destination.Close() + if err == nil { + err = closeErr + } + } + if err != nil { + _ = os.Remove(target) + } + } + } else { + err = storage.Put(r.Context(), objectKey, contentType, size, file) + } + if err != nil { + cleanupRecord() + log.Printf("media upload failed provider=%s bucket=%s object=%s original=%q error=%v", provider, bucket, objectKey, filepath.Base(fileHeader.Filename), err) + fail(w, http.StatusBadGateway, 50001, "文件上传失败,请检查存储配置后重试") + return + } + if _, err = a.db.ExecContext(r.Context(), `UPDATE media_assets SET public_url=?,status=1 WHERE id=? AND status=0`, publicURL, mediaID); err != nil { + if provider == "local" { + _ = os.Remove(filepath.Join(a.configPlain(r.Context(), "storage.local.directory", a.config.MediaDir), filepath.Base(objectKey))) + } else { + _ = storage.Delete(r.Context(), objectKey) + } + cleanupRecord() + fail(w, http.StatusInternalServerError, 50001, "完成媒体记录失败") + return + } + reply(w, map[string]any{ + "id": mediaID, + "name": name, + "url": publicURL, + "provider": provider, + "objectKey": objectKey, + }) +} + +func (a *App) serveMedia(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + name := parts[len(parts)-1] + if !mediaNamePattern.MatchString(name) { + http.NotFound(w, r) + return + } + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Set("X-Content-Type-Options", "nosniff") + directory := a.configPlain(r.Context(), "storage.local.directory", a.config.MediaDir) + http.ServeFile(w, r, filepath.Join(directory, filepath.Base(name))) +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/membership.go b/im/.deploy/application-20260831-120612/backend/internal/app/membership.go new file mode 100644 index 0000000..6dece38 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/membership.go @@ -0,0 +1,344 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +func (a *App) availablePaymentChannels(ctx context.Context) []map[string]any { + mode := a.configPlain(ctx, "payment.mode", "sandbox") + gatewayReady := mode == "sandbox" || a.paymentGatewayConfigured(ctx) + if a.config.Environment == "production" && mode == "sandbox" { + gatewayReady = false + } + channels := []map[string]any{} + if a.configBool(ctx, "payment.alipay.enabled", true) { + channels = append(channels, map[string]any{"code": "alipay", "name": "支付宝", "icon": "支", "configured": gatewayReady}) + } + if a.configBool(ctx, "payment.wechat.enabled", true) { + channels = append(channels, map[string]any{"code": "wechat", "name": "微信支付", "icon": "微", "configured": gatewayReady}) + } + return channels +} + +func (a *App) paymentChannels(w http.ResponseWriter, r *http.Request) { + reply(w, map[string]any{"mode": a.configPlain(r.Context(), "payment.mode", "sandbox"), "items": a.availablePaymentChannels(r.Context())}) +} + +type planView struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Level int `json:"level"` + DurationDays int `json:"durationDays"` + DailyActiveChatLimit int `json:"dailyActiveChatLimit"` + DailyLikeLimit int `json:"dailyLikeLimit"` + CanViewVisitors bool `json:"canViewVisitors"` + CanInvisibleVisit bool `json:"canInvisibleVisit"` + RecommendationWeight int `json:"recommendationWeight"` + PriceCent int `json:"priceCent"` + OriginalPriceCent int `json:"originalPriceCent"` + Status int `json:"status"` + SortOrder int `json:"sortOrder"` +} + +func (a *App) membershipPlans(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,code,name,level,duration_days,daily_active_chat_limit,daily_like_limit,can_view_visitors,can_invisible_visit,recommendation_weight,price_cent,original_price_cent,status,sort_order FROM membership_plans WHERE status=1 AND deleted_at IS NULL ORDER BY sort_order`) + if err != nil { + fail(w, 500, 50001, "查询套餐失败") + return + } + defer rows.Close() + items := []planView{} + for rows.Next() { + var item planView + _ = rows.Scan(&item.ID, &item.Code, &item.Name, &item.Level, &item.DurationDays, &item.DailyActiveChatLimit, &item.DailyLikeLimit, &item.CanViewVisitors, &item.CanInvisibleVisit, &item.RecommendationWeight, &item.PriceCent, &item.OriginalPriceCent, &item.Status, &item.SortOrder) + items = append(items, item) + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) membershipStatus(w http.ResponseWriter, r *http.Request) { + quota := a.dailyActiveChatQuota(r.Context(), current(r).ID) + entitlements := a.resolveMembershipEntitlements(r.Context(), current(r).ID) + var likeUsed int + _ = a.db.QueryRowContext(r.Context(), `SELECT used_count FROM user_daily_like_usage WHERE user_id=? AND usage_date=CURRENT_DATE()`, current(r).ID).Scan(&likeUsed) + likeRemaining := -1 + if entitlements.DailyLikeLimit > 0 { + likeRemaining = entitlements.DailyLikeLimit - likeUsed + if likeRemaining < 0 { + likeRemaining = 0 + } + } + entitlementView := map[string]any{"dailyActiveChatLimit": entitlements.DailyActiveChatLimit, "dailyLikeLimit": entitlements.DailyLikeLimit, "dailyLikeUsed": likeUsed, "dailyLikeRemaining": likeRemaining, "canViewVisitors": entitlements.CanViewVisitors, "canInvisibleVisit": entitlements.CanInvisibleVisit, "recommendationWeight": entitlements.RecommendationWeight} + var planName string + var level int + var expires time.Time + err := a.db.QueryRowContext(r.Context(), `SELECT p.name,p.level,s.expires_at FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id WHERE s.user_id=? AND s.status=1 AND s.started_at<=NOW(3) AND s.expires_at>NOW(3) ORDER BY p.level DESC,s.expires_at DESC LIMIT 1`, current(r).ID).Scan(&planName, &level, &expires) + if err != nil { + reply(w, map[string]any{"active": false, "dailyActiveChat": quota, "entitlements": entitlementView, "level": 0, "name": "普通用户"}) + return + } + reply(w, map[string]any{"active": true, "dailyActiveChat": quota, "entitlements": entitlementView, "level": level, "name": planName, "expiresAt": expires}) +} + +func (a *App) createOrder(w http.ResponseWriter, r *http.Request) { + var req struct { + PlanID int64 `json:"planId"` + Channel string `json:"channel"` + } + if decode(r, &req) != nil || req.PlanID == 0 { + fail(w, 400, 20001, "请选择套餐") + return + } + channels := a.availablePaymentChannels(r.Context()) + if req.Channel == "" && len(channels) > 0 { + req.Channel, _ = channels[0]["code"].(string) + } + channelAllowed := false + for _, channel := range channels { + if channel["code"] == req.Channel { + channelAllowed = channel["configured"] == true + } + } + if !channelAllowed { + fail(w, 400, 20001, "支付渠道未启用或配置不完整") + return + } + var price int + if a.db.QueryRowContext(r.Context(), `SELECT price_cent FROM membership_plans WHERE id=? AND status=1 AND deleted_at IS NULL`, req.PlanID).Scan(&price) != nil { + fail(w, 404, 30001, "套餐不存在") + return + } + orderNo := fmt.Sprintf("XY%d%d%s", time.Now().UnixMilli(), current(r).ID, randomToken()[:8]) + result, err := a.db.ExecContext(r.Context(), `INSERT INTO orders(order_no,user_id,product_type,product_id,amount_cent,status,channel)VALUES(?,?,'membership',?,?,'CREATED',?)`, orderNo, current(r).ID, req.PlanID, price, req.Channel) + if err != nil { + fail(w, 500, 50001, "创建订单失败") + return + } + id, _ := result.LastInsertId() + reply(w, map[string]any{"id": id, "orderNo": orderNo, "amountCent": price, "status": "CREATED", "channel": req.Channel}) +} + +func (a *App) payOrder(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "订单编号无效") + return + } + mode := a.configPlain(r.Context(), "payment.mode", "sandbox") + if mode != "sandbox" && mode != "live" { + fail(w, http.StatusServiceUnavailable, 50003, "支付模式配置无效") + return + } + if mode == "live" { + var orderNo, status, channel, subject, providerOrderNo, checkoutURL string + var amountCent int + var paymentPayload sql.NullString + err = a.db.QueryRowContext(r.Context(), `SELECT o.order_no,o.amount_cent,o.status,o.channel,COALESCE(p.name,'会员套餐'),o.provider_order_no,o.checkout_url,o.payment_payload FROM orders o LEFT JOIN membership_plans p ON p.id=o.product_id WHERE o.id=? AND o.user_id=? AND o.deleted_at IS NULL`, id, current(r).ID).Scan(&orderNo, &amountCent, &status, &channel, &subject, &providerOrderNo, &checkoutURL, &paymentPayload) + if err != nil { + fail(w, http.StatusNotFound, 30001, "订单不存在") + return + } + if status == "PAID" { + reply(w, map[string]any{"success": true, "status": "PAID", "mode": mode}) + return + } + if status != "CREATED" { + fail(w, http.StatusBadRequest, 20001, "当前订单状态无法支付") + return + } + if providerOrderNo != "" && (checkoutURL != "" || paymentPayload.Valid) { + var appPayload map[string]any + if paymentPayload.Valid && paymentPayload.String != "" { + _ = json.Unmarshal([]byte(paymentPayload.String), &appPayload) + } + reply(w, map[string]any{"success": true, "mode": mode, "status": status, "providerOrderNo": providerOrderNo, "checkoutUrl": checkoutURL, "appPayload": appPayload}) + return + } + gatewayResult, gatewayErr := a.createGatewayPayment(r.Context(), paymentGatewayOrder{OrderNo: orderNo, AmountCent: amountCent, Channel: channel, Subject: subject, UserID: current(r).ID}) + if gatewayErr != nil { + fail(w, http.StatusBadGateway, 50003, "支付网关下单失败") + return + } + payloadJSON := "" + if len(gatewayResult.AppPayload) > 0 { + encoded, _ := json.Marshal(gatewayResult.AppPayload) + payloadJSON = string(encoded) + } + if _, err = a.db.ExecContext(r.Context(), `UPDATE orders SET provider_order_no=?,checkout_url=?,payment_payload=? WHERE id=? AND user_id=? AND status='CREATED' AND provider_order_no=''`, gatewayResult.ProviderOrderNo, gatewayResult.CheckoutURL, payloadJSON, id, current(r).ID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存支付信息失败") + return + } + reply(w, map[string]any{"success": true, "mode": mode, "status": status, "providerOrderNo": gatewayResult.ProviderOrderNo, "checkoutUrl": gatewayResult.CheckoutURL, "appPayload": gatewayResult.AppPayload}) + return + } + if a.config.Environment == "production" { + fail(w, http.StatusServiceUnavailable, 50003, "生产环境禁止沙箱支付") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "支付失败") + return + } + defer func() { _ = tx.Rollback() }() + var userID, planID int64 + var amountCent int + var orderNo string + var status string + err = tx.QueryRowContext(r.Context(), `SELECT user_id,product_id,amount_cent,order_no,status FROM orders WHERE id=? AND user_id=? AND deleted_at IS NULL FOR UPDATE`, id, current(r).ID).Scan(&userID, &planID, &amountCent, &orderNo, &status) + if err != nil { + fail(w, 404, 30001, "订单不存在") + return + } + if status == "PAID" { + reply(w, map[string]any{"success": true, "status": "PAID"}) + return + } + if status != "CREATED" { + fail(w, 400, 20001, "当前订单状态无法支付") + return + } + var durationDays, level int + if err = tx.QueryRowContext(r.Context(), `SELECT duration_days,level FROM membership_plans WHERE id=? AND status=1 AND deleted_at IS NULL`, planID).Scan(&durationDays, &level); err != nil { + fail(w, 400, 20001, "会员套餐已下架") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='PAID',paid_at=NOW(3),paid_amount_cent=?,provider_order_no=?,payment_notified_at=NOW(3) WHERE id=?`, amountCent, "sandbox:"+orderNo, id); err == nil { + err = grantOrderMembershipTx(r.Context(), tx, id, userID, planID, durationDays, level) + } + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "支付入账失败") + return + } + reply(w, map[string]any{"success": true, "status": "PAID", "mode": mode}) +} + +func (a *App) orderStatus(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "订单编号无效") + return + } + var orderNo, status, channel, productName, refundReason string + var amountCent int + var paidAt, refundRequestedAt sql.NullTime + if err = a.db.QueryRowContext(r.Context(), `SELECT o.order_no,o.amount_cent,o.status,o.channel,o.paid_at,COALESCE(p.name,'已删除套餐'),o.refund_reason,o.refund_requested_at FROM orders o LEFT JOIN membership_plans p ON p.id=o.product_id WHERE o.id=? AND o.user_id=? AND o.deleted_at IS NULL`, id, current(r).ID).Scan(&orderNo, &amountCent, &status, &channel, &paidAt, &productName, &refundReason, &refundRequestedAt); err != nil { + fail(w, http.StatusNotFound, 30001, "订单不存在") + return + } + result := map[string]any{"id": id, "orderNo": orderNo, "amountCent": amountCent, "status": status, "channel": channel, "productName": productName, "refundReason": refundReason, "refundRequestedAt": nullableTime(refundRequestedAt)} + if paidAt.Valid { + result["paidAt"] = paidAt.Time + } + reply(w, result) +} + +func (a *App) myOrders(w http.ResponseWriter, r *http.Request) { + page, pageSize, offset := pageOptions(r) + var total int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM orders WHERE user_id=? AND deleted_at IS NULL`, current(r).ID).Scan(&total); err != nil { + fail(w, 500, 50001, "查询失败") + return + } + rows, err := a.db.QueryContext(r.Context(), `SELECT o.id,o.order_no,o.amount_cent,o.status,o.channel,o.created_at,COALESCE(p.name,'已删除套餐') FROM orders o LEFT JOIN membership_plans p ON p.id=o.product_id WHERE o.user_id=? AND o.deleted_at IS NULL ORDER BY o.created_at DESC LIMIT ? OFFSET ?`, current(r).ID, pageSize, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var orderNo, status, channel, name string + var amount int + var created time.Time + _ = rows.Scan(&id, &orderNo, &amount, &status, &channel, &created, &name) + items = append(items, map[string]any{"id": id, "orderNo": orderNo, "amountCent": amount, "status": status, "channel": channel, "createdAt": created, "productName": name}) + } + reply(w, map[string]any{"items": items, "page": page, "pageSize": pageSize, "total": total, "hasMore": offset+len(items) < total}) +} + +func (a *App) notifications(w http.ResponseWriter, r *http.Request) { + page, pageSize, offset := pageOptions(r) + notificationType := strings.TrimSpace(r.URL.Query().Get("type")) + where := ` WHERE user_id=?` + args := []any{current(r).ID} + if notificationType != "" { + where += ` AND type=?` + args = append(args, notificationType) + } + args = append(args, pageSize, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT id,type,title,content,biz_type,biz_id,read_at,created_at FROM notifications`+where+` ORDER BY created_at DESC LIMIT ? OFFSET ?`, args...) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var typ, title, content, bizType string + var bizID any + var readAt any + var created time.Time + _ = rows.Scan(&id, &typ, &title, &content, &bizType, &bizID, &readAt, &created) + items = append(items, map[string]any{"id": id, "type": typ, "title": title, "content": content, "bizType": bizType, "bizId": bizID, "readAt": readAt, "createdAt": created}) + } + var unread, total int + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM notifications WHERE user_id=? AND read_at IS NULL`, current(r).ID).Scan(&unread) + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM notifications`+where, args[:len(args)-2]...).Scan(&total) + rowsByType, _ := a.db.QueryContext(r.Context(), `SELECT type,COUNT(*) FROM notifications WHERE user_id=? AND read_at IS NULL GROUP BY type`, current(r).ID) + unreadByType := map[string]int{} + if rowsByType != nil { + defer rowsByType.Close() + for rowsByType.Next() { + var typ string + var count int + _ = rowsByType.Scan(&typ, &count) + unreadByType[typ] = count + } + } + reply(w, map[string]any{"items": items, "page": page, "pageSize": pageSize, "total": total, "unread": unread, "unreadByType": unreadByType, "hasMore": offset+len(items) < total}) +} +func (a *App) readAllNotifications(w http.ResponseWriter, r *http.Request) { + _, _ = a.db.ExecContext(r.Context(), `UPDATE notifications SET read_at=NOW(3) WHERE user_id=? AND read_at IS NULL`, current(r).ID) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) appConfig(w http.ResponseWriter, r *http.Request) { + rows, _ := a.db.QueryContext(r.Context(), `SELECT config_key,config_value,value_type FROM system_configs WHERE value_type<>'secret' AND config_key LIKE 'app.%'`) + configs := map[string]any{} + if rows != nil { + defer rows.Close() + for rows.Next() { + var key, value, typ string + _ = rows.Scan(&key, &value, &typ) + configs[key] = value + } + } + platform := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("platform"))) + if platform != "android" && platform != "ios" && platform != "h5" { + platform = "h5" + } + latest := map[string]any{} + var version, downloadURL, notes string + var build int + var force bool + if a.db.QueryRowContext(r.Context(), `SELECT version,build_number,force_update,download_url,release_notes FROM app_versions WHERE platform=? AND status=1 ORDER BY build_number DESC LIMIT 1`, platform).Scan(&version, &build, &force, &downloadURL, ¬es) == nil { + latest = map[string]any{"version": version, "buildNumber": build, "forceUpdate": force, "downloadUrl": downloadURL, "releaseNotes": notes} + } + features := map[string]bool{ + "nearby": a.configBool(r.Context(), "app.features.nearby", true), + "feed": a.configBool(r.Context(), "app.features.feed", true), + "membership": a.configBool(r.Context(), "app.features.membership", true), + "im": a.configBool(r.Context(), "app.features.im", true), + } + reply(w, map[string]any{"configs": configs, "platform": platform, "features": features, "maintenance": map[string]any{"enabled": a.configBool(r.Context(), "app.maintenance.enabled", false), "message": a.configPlain(r.Context(), "app.maintenance.message", "系统维护中,请稍后再试")}, "legal": map[string]string{"userAgreementVersion": a.configPlain(r.Context(), "legal.user_agreement_version", "1.0"), "privacyPolicyVersion": a.configPlain(r.Context(), "legal.privacy_policy_version", "1.0"), "operatorName": a.configPlain(r.Context(), "legal.operator_name", ""), "contact": a.configPlain(r.Context(), "legal.contact", ""), "effectiveDate": a.configPlain(r.Context(), "legal.effective_date", ""), "userAgreementUrl": a.configPlain(r.Context(), "legal.user_agreement_url", ""), "privacyPolicyUrl": a.configPlain(r.Context(), "legal.privacy_policy_url", "")}, "minVersion": a.configPlain(r.Context(), "app.min_version."+platform, "1.0.0"), "latest": latest}) +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/membership_limits.go b/im/.deploy/application-20260831-120612/backend/internal/app/membership_limits.go new file mode 100644 index 0000000..16e5323 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/membership_limits.go @@ -0,0 +1,122 @@ +package app + +import ( + "context" + "database/sql" + "fmt" + "strconv" +) + +const defaultFreeDailyActiveChatLimit = 5 + +type rowQuerier interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +type dailyActiveChatLimitError struct { + Limit int +} + +func (e *dailyActiveChatLimitError) Error() string { + return fmt.Sprintf("今日主动聊天人数已达上限(%d人),回复收到的消息不受此限制", e.Limit) +} + +func (a *App) resolveDailyActiveChatLimit(ctx context.Context, queryer rowQuerier, userID int64) int { + var limit int + err := queryer.QueryRowContext(ctx, `SELECT p.daily_active_chat_limit + FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id + WHERE s.user_id=? AND s.status=1 AND s.started_at<=NOW(3) AND s.expires_at>NOW(3) + ORDER BY p.level DESC,s.expires_at DESC LIMIT 1`, userID).Scan(&limit) + if err == nil { + return limit + } + + var raw string + if err = queryer.QueryRowContext(ctx, `SELECT config_value FROM system_configs WHERE config_key='membership.free_daily_active_chat_limit'`).Scan(&raw); err == nil { + if parsed, parseErr := strconv.Atoi(raw); parseErr == nil && parsed >= 0 { + return parsed + } + } + return defaultFreeDailyActiveChatLimit +} + +func dailyActiveChatQuotaView(limit, used int) map[string]any { + remaining := -1 + unlimited := limit == 0 + if !unlimited { + remaining = limit - used + if remaining < 0 { + remaining = 0 + } + } + return map[string]any{ + "limit": limit, + "remaining": remaining, + "unlimited": unlimited, + "used": used, + } +} + +func (a *App) dailyActiveChatQuota(ctx context.Context, userID int64) map[string]any { + limit := a.resolveDailyActiveChatLimit(ctx, a.db, userID) + var used int + _ = a.db.QueryRowContext(ctx, `SELECT used_count FROM im_daily_active_chat_usage WHERE user_id=? AND usage_date=CURRENT_DATE()`, userID).Scan(&used) + return dailyActiveChatQuotaView(limit, used) +} + +func (a *App) reserveDailyActiveChat(ctx context.Context, tx *sql.Tx, conversationID, senderID int64) error { + var user1ID, user2ID int64 + err := tx.QueryRowContext(ctx, `SELECT user1_id,user2_id FROM im_direct_conversations WHERE conversation_id=?`, conversationID).Scan(&user1ID, &user2ID) + if err == sql.ErrNoRows { + return nil + } + if err != nil { + return err + } + targetUserID := user1ID + if senderID == user1ID { + targetUserID = user2ID + } else if senderID != user2ID { + return fmt.Errorf("不是会话成员") + } + + var inboundToday int + if err = tx.QueryRowContext(ctx, `SELECT EXISTS( + SELECT 1 FROM im_messages + WHERE conversation_id=? AND sender_id=? + AND created_at>=CURRENT_DATE() AND created_at 0 && used >= limit { + return &dailyActiveChatLimitError{Limit: limit} + } + if _, err = tx.ExecContext(ctx, `INSERT INTO im_daily_active_chat_targets(user_id,target_user_id,usage_date,conversation_id) VALUES(?,?,CURRENT_DATE(),?)`, senderID, targetUserID, conversationID); err != nil { + return err + } + _, err = tx.ExecContext(ctx, `UPDATE im_daily_active_chat_usage SET used_count=used_count+1 WHERE user_id=? AND usage_date=CURRENT_DATE()`, senderID) + return err +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/membership_limits_test.go b/im/.deploy/application-20260831-120612/backend/internal/app/membership_limits_test.go new file mode 100644 index 0000000..0afd489 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/membership_limits_test.go @@ -0,0 +1,25 @@ +package app + +import "testing" + +func TestDailyActiveChatQuotaView(t *testing.T) { + limited := dailyActiveChatQuotaView(20, 7) + if limited["remaining"] != 13 || limited["unlimited"] != false { + t.Fatalf("unexpected limited quota: %#v", limited) + } + exhausted := dailyActiveChatQuotaView(5, 8) + if exhausted["remaining"] != 0 { + t.Fatalf("remaining quota must not be negative: %#v", exhausted) + } + unlimited := dailyActiveChatQuotaView(0, 99) + if unlimited["remaining"] != -1 || unlimited["unlimited"] != true { + t.Fatalf("unexpected unlimited quota: %#v", unlimited) + } +} + +func TestDailyActiveChatLimitError(t *testing.T) { + err := (&dailyActiveChatLimitError{Limit: 20}).Error() + if err != "今日主动聊天人数已达上限(20人),回复收到的消息不受此限制" { + t.Fatalf("unexpected quota message: %s", err) + } +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/password_change_test.go b/im/.deploy/application-20260831-120612/backend/internal/app/password_change_test.go new file mode 100644 index 0000000..a7a4197 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/password_change_test.go @@ -0,0 +1,92 @@ +package app + +import ( + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type passwordChangeDB struct { + oauthTestDB + hash, pending string + revoked, pendingRevoked bool + failedWrite bool +} +type passwordChangeConnector struct{ db *passwordChangeDB } + +func (c passwordChangeConnector) Connect(context.Context) (driver.Conn, error) { return c.db, nil } +func (passwordChangeConnector) Driver() driver.Driver { return oauthTestDriver{} } +func (s *passwordChangeDB) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) { + s.pending, s.pendingRevoked = s.hash, s.revoked + return s, nil +} +func (s *passwordChangeDB) Commit() error { + s.hash, s.revoked = s.pending, s.pendingRevoked + return nil +} +func (s *passwordChangeDB) Rollback() error { s.pending = ""; return nil } +func (s *passwordChangeDB) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { + if strings.Contains(query, "SELECT password_hash FROM admin_users") { + return oauthRow(s.hash), nil + } + return s.oauthTestDB.QueryContext(ctx, query, args) +} +func (s *passwordChangeDB) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + switch { + case strings.HasPrefix(query, "UPDATE admin_users SET password_hash="): + s.pending = args[0].Value.(string) + case strings.HasPrefix(query, "UPDATE admin_sessions SET revoked_at="): + if s.failedWrite { + return nil, fmt.Errorf("session write failed") + } + s.pendingRevoked = true + case strings.HasPrefix(query, "INSERT INTO admin_audit_logs"): + default: + return s.oauthTestDB.ExecContext(ctx, query, args) + } + return driver.RowsAffected(1), nil +} + +func TestAdminPasswordChangeWithoutStrengthRules(t *testing.T) { + for _, password := range []string{"1", "lowercase", "中文", "OldPassword123!", strings.Repeat("长", 80)} { + t.Run(fmt.Sprintf("bytes-%d", len(password)), func(t *testing.T) { + original, err := hashPassword("OldPassword123!") + if err != nil { + t.Fatal(err) + } + store := &passwordChangeDB{hash: original} + db := sql.OpenDB(passwordChangeConnector{store}) + t.Cleanup(func() { _ = db.Close() }) + a := &App{db: db} + request := func(current, next string) *httptest.ResponseRecorder { + payload, _ := json.Marshal(map[string]string{"currentPassword": current, "newPassword": next}) + r := httptest.NewRequest(http.MethodPut, "/admin/v1/me/password", strings.NewReader(string(payload))) + r = r.WithContext(context.WithValue(r.Context(), identityKey{}, identity{ID: 7, Role: "admin"})) + w := httptest.NewRecorder() + a.adminChangePassword(w, r) + return w + } + if w := request("wrong", password); w.Code != http.StatusBadRequest || store.hash != original { + t.Fatal("incorrect old password accepted") + } + if w := request("OldPassword123!", ""); w.Code != http.StatusBadRequest { + t.Fatal("empty new password accepted") + } + store.failedWrite = true + if w := request("OldPassword123!", password); w.Code != http.StatusInternalServerError || store.hash != original { + t.Fatal("failed session revocation did not roll back the password") + } + store.failedWrite = false + w := request("OldPassword123!", password) + if w.Code != http.StatusOK || !store.revoked || !checkPassword(store.hash, password) { + t.Fatalf("password change failed: %d %s", w.Code, w.Body.String()) + } + }) + } +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/payment_gateway.go b/im/.deploy/application-20260831-120612/backend/internal/app/payment_gateway.go new file mode 100644 index 0000000..0abef90 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/payment_gateway.go @@ -0,0 +1,347 @@ +package app + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type paymentGatewayOrder struct { + OrderNo string + AmountCent int + Channel string + Subject string + UserID int64 +} + +type paymentGatewayResult struct { + ProviderOrderNo string `json:"providerOrderNo"` + CheckoutURL string `json:"checkoutUrl"` + AppPayload map[string]any `json:"appPayload"` +} + +type paymentNotifyRequest struct { + EventID string `json:"eventId"` + OrderNo string `json:"orderNo"` + Channel string `json:"channel"` + ProviderOrderNo string `json:"providerOrderNo"` + Status string `json:"status"` + AmountCent int `json:"amountCent"` +} + +func validHTTPSURL(raw string) bool { + parsed, err := url.Parse(strings.TrimSpace(raw)) + return err == nil && parsed.Scheme == "https" && parsed.Host != "" +} + +func (a *App) paymentGatewayConfigured(ctx context.Context) bool { + createURL := a.configPlain(ctx, "payment.gateway.create_url", "") + refundURL := a.configPlain(ctx, "payment.gateway.refund_url", "") + notifyURL := a.configPlain(ctx, "payment.gateway.notify_url", "") + secret := a.configPlain(ctx, "payment.gateway.notify_secret", "") + token := a.configPlain(ctx, "payment.gateway.token", "") + if createURL == "" || refundURL == "" || notifyURL == "" || len(secret) < 32 || token == "" { + return false + } + if a.config.Environment == "production" && (!validHTTPSURL(createURL) || !validHTTPSURL(refundURL) || !validHTTPSURL(notifyURL)) { + return false + } + return true +} + +func (a *App) createGatewayPayment(ctx context.Context, order paymentGatewayOrder) (paymentGatewayResult, error) { + if !a.paymentGatewayConfigured(ctx) { + return paymentGatewayResult{}, fmt.Errorf("payment gateway is not completely configured") + } + payload, err := json.Marshal(map[string]any{ + "orderNo": order.OrderNo, "amountCent": order.AmountCent, "currency": "CNY", + "channel": order.Channel, "subject": order.Subject, "userId": order.UserID, + "notifyUrl": a.configPlain(ctx, "payment.gateway.notify_url", ""), + "returnUrl": a.configPlain(ctx, "payment.gateway.return_url", ""), + }) + if err != nil { + return paymentGatewayResult{}, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.configPlain(ctx, "payment.gateway.create_url", ""), bytes.NewReader(payload)) + if err != nil { + return paymentGatewayResult{}, err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", "Bearer "+a.configPlain(ctx, "payment.gateway.token", "")) + request.Header.Set("Idempotency-Key", order.OrderNo) + + timeout, _ := strconv.Atoi(a.configPlain(ctx, "payment.gateway.timeout_seconds", "10")) + if timeout < 3 || timeout > 30 { + timeout = 10 + } + response, err := (&http.Client{Timeout: time.Duration(timeout) * time.Second}).Do(request) + if err != nil { + return paymentGatewayResult{}, fmt.Errorf("payment gateway request failed: %w", err) + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, 512<<10)) + if err != nil { + return paymentGatewayResult{}, fmt.Errorf("read payment gateway response: %w", err) + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return paymentGatewayResult{}, fmt.Errorf("payment gateway returned HTTP %d", response.StatusCode) + } + + var envelope struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + } + var result paymentGatewayResult + if err = json.Unmarshal(body, &envelope); err == nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" { + if envelope.Code != 0 { + return paymentGatewayResult{}, fmt.Errorf("payment gateway rejected request: %s", envelope.Message) + } + err = json.Unmarshal(envelope.Data, &result) + } else { + err = json.Unmarshal(body, &result) + } + if err != nil { + return paymentGatewayResult{}, fmt.Errorf("invalid payment gateway response: %w", err) + } + if strings.TrimSpace(result.ProviderOrderNo) == "" || (result.CheckoutURL == "" && len(result.AppPayload) == 0) { + return paymentGatewayResult{}, fmt.Errorf("payment gateway response is incomplete") + } + if result.CheckoutURL != "" && a.config.Environment == "production" && !validHTTPSURL(result.CheckoutURL) { + return paymentGatewayResult{}, fmt.Errorf("payment gateway returned a non-HTTPS checkout URL") + } + return result, nil +} + +func (a *App) paymentNotify(w http.ResponseWriter, r *http.Request) { + if !a.paymentGatewayConfigured(r.Context()) { + fail(w, http.StatusServiceUnavailable, 50003, "支付网关未配置") + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 128<<10)) + if err != nil || len(body) == 0 { + fail(w, http.StatusBadRequest, 20001, "支付通知内容无效") + return + } + timestamp := strings.TrimSpace(r.Header.Get("X-Xingyu-Timestamp")) + signature := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("X-Xingyu-Signature"), "sha256=")) + unixSeconds, parseErr := strconv.ParseInt(timestamp, 10, 64) + if parseErr != nil || time.Since(time.Unix(unixSeconds, 0)) > 5*time.Minute || time.Until(time.Unix(unixSeconds, 0)) > 5*time.Minute { + fail(w, http.StatusUnauthorized, 10006, "支付通知时间戳无效") + return + } + mac := hmac.New(sha256.New, []byte(a.configPlain(r.Context(), "payment.gateway.notify_secret", ""))) + _, _ = mac.Write([]byte(timestamp + ".")) + _, _ = mac.Write(body) + expected := hex.EncodeToString(mac.Sum(nil)) + if len(signature) != len(expected) || subtle.ConstantTimeCompare([]byte(strings.ToLower(signature)), []byte(expected)) != 1 { + fail(w, http.StatusUnauthorized, 10006, "支付通知签名无效") + return + } + var notice paymentNotifyRequest + if json.Unmarshal(body, ¬ice) != nil || notice.EventID == "" || notice.OrderNo == "" || notice.Channel == "" || notice.ProviderOrderNo == "" || notice.AmountCent <= 0 { + fail(w, http.StatusBadRequest, 20001, "支付通知字段不完整") + return + } + notice.Status = strings.ToUpper(strings.TrimSpace(notice.Status)) + if notice.Status != "PAID" && notice.Status != "FAILED" && notice.Status != "CLOSED" && notice.Status != "REFUNDED" && notice.Status != "REFUND_FAILED" { + fail(w, http.StatusBadRequest, 20001, "支付通知状态无效") + return + } + if err = a.settlePayment(r.Context(), notice, string(body)); err != nil { + fail(w, http.StatusConflict, 20001, err.Error()) + return + } + reply(w, map[string]any{"success": true, "eventId": notice.EventID}) +} + +func (a *App) settlePayment(ctx context.Context, notice paymentNotifyRequest, raw string) error { + tx, err := a.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(ctx, `INSERT IGNORE INTO payment_events(event_id,order_no,channel,provider_order_no,event_status,amount_cent,raw_payload) VALUES(?,?,?,?,?,?,?)`, notice.EventID, notice.OrderNo, notice.Channel, notice.ProviderOrderNo, notice.Status, notice.AmountCent, raw) + if err != nil { + return err + } + affected, _ := result.RowsAffected() + if affected == 0 { + return tx.Commit() + } + + var orderID, userID, planID int64 + var amountCent int + var status, channel string + if err = tx.QueryRowContext(ctx, `SELECT id,user_id,product_id,amount_cent,status,channel FROM orders WHERE order_no=? AND deleted_at IS NULL FOR UPDATE`, notice.OrderNo).Scan(&orderID, &userID, &planID, &amountCent, &status, &channel); err != nil { + return fmt.Errorf("order does not exist") + } + if channel != notice.Channel || amountCent != notice.AmountCent { + return fmt.Errorf("payment amount or channel does not match the order") + } + if notice.Status == "REFUNDED" { + if status == "REFUNDED" { + return tx.Commit() + } + if status != "PAID" && status != "REFUNDING" { + return fmt.Errorf("order status does not allow refund") + } + if _, err = tx.ExecContext(ctx, `UPDATE orders SET status='REFUNDED',payment_notified_at=NOW(3) WHERE id=?`, orderID); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1 AND source IN (?,?)`, userID, fmt.Sprintf("order:%d", orderID), fmt.Sprintf("admin_order:%d", orderID)); err != nil { + return err + } + if err = a.recomputeMembershipTx(ctx, tx, userID); err != nil { + return err + } + return tx.Commit() + } + if notice.Status == "REFUND_FAILED" { + if status == "REFUNDING" { + if _, err = tx.ExecContext(ctx, `UPDATE orders SET status='PAID',payment_notified_at=NOW(3) WHERE id=?`, orderID); err != nil { + return err + } + } + return tx.Commit() + } + if notice.Status == "PAID" && (status == "PAID" || status == "REFUNDING" || status == "REFUNDED") { + return tx.Commit() + } + if status != "CREATED" { + return fmt.Errorf("order status does not allow payment") + } + if notice.Status != "PAID" { + return tx.Commit() + } + + var durationDays, level int + if err = tx.QueryRowContext(ctx, `SELECT duration_days,level FROM membership_plans WHERE id=? AND deleted_at IS NULL`, planID).Scan(&durationDays, &level); err != nil { + return fmt.Errorf("membership plan does not exist") + } + if _, err = tx.ExecContext(ctx, `UPDATE orders SET status='PAID',paid_at=NOW(3),paid_amount_cent=?,provider_order_no=?,payment_notified_at=NOW(3) WHERE id=?`, notice.AmountCent, notice.ProviderOrderNo, orderID); err != nil { + return err + } + if err = grantOrderMembershipTx(ctx, tx, orderID, userID, planID, durationDays, level); err != nil { + return err + } + return tx.Commit() +} + +func (a *App) createGatewayRefund(ctx context.Context, orderNo, providerOrderNo string, amountCent int) error { + endpoint := a.configPlain(ctx, "payment.gateway.refund_url", "") + if endpoint == "" || (a.config.Environment == "production" && !validHTTPSURL(endpoint)) { + return fmt.Errorf("payment refund gateway is not configured") + } + payload, err := json.Marshal(map[string]any{ + "orderNo": orderNo, "providerOrderNo": providerOrderNo, "amountCent": amountCent, + "currency": "CNY", "reason": "admin_requested", + }) + if err != nil { + return err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", "Bearer "+a.configPlain(ctx, "payment.gateway.token", "")) + request.Header.Set("Idempotency-Key", "refund:"+orderNo) + timeout, _ := strconv.Atoi(a.configPlain(ctx, "payment.gateway.timeout_seconds", "10")) + if timeout < 3 || timeout > 30 { + timeout = 10 + } + response, err := (&http.Client{Timeout: time.Duration(timeout) * time.Second}).Do(request) + if err != nil { + return fmt.Errorf("refund gateway request failed: %w", err) + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, 256<<10)) + if err != nil { + return err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("refund gateway returned HTTP %d", response.StatusCode) + } + var gatewayResponse struct { + Code *int `json:"code"` + Success *bool `json:"success"` + Message string `json:"message"` + } + if len(bytes.TrimSpace(body)) > 0 { + if err = json.Unmarshal(body, &gatewayResponse); err != nil { + return fmt.Errorf("refund gateway returned invalid JSON: %w", err) + } + if (gatewayResponse.Code != nil && *gatewayResponse.Code != 0) || (gatewayResponse.Success != nil && !*gatewayResponse.Success) { + return fmt.Errorf("refund gateway rejected request: %s", gatewayResponse.Message) + } + } + return nil +} + +func (a *App) requestLiveRefund(w http.ResponseWriter, r *http.Request, orderID int64) { + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建退款申请失败") + return + } + defer func() { _ = tx.Rollback() }() + var userID int64 + var amountCent int + var orderNo, providerOrderNo, status string + if err = tx.QueryRowContext(r.Context(), `SELECT user_id,amount_cent,order_no,provider_order_no,status FROM orders WHERE id=? AND deleted_at IS NULL FOR UPDATE`, orderID).Scan(&userID, &amountCent, &orderNo, &providerOrderNo, &status); err != nil { + fail(w, http.StatusNotFound, 30001, "订单不存在") + return + } + if status != "PAID" && status != "REFUND_REQUESTED" && status != "REFUNDING" { + fail(w, http.StatusBadRequest, 20001, "只有已支付、用户已申请退款或退款处理中的订单可发起退款") + return + } + if providerOrderNo == "" { + fail(w, http.StatusBadRequest, 20001, "订单缺少支付渠道流水号,不能自动退款") + return + } + if status == "PAID" || status == "REFUND_REQUESTED" { + if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='REFUNDING' WHERE id=?`, orderID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新退款状态失败") + return + } + } + if err = tx.Commit(); err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建退款申请失败") + return + } + if err = a.createGatewayRefund(r.Context(), orderNo, providerOrderNo, amountCent); err != nil { + a.audit(r, "refund_request_failed", "order", orderID, map[string]any{"userId": userID, "error": err.Error()}) + fail(w, http.StatusBadGateway, 50003, "退款网关请求失败,订单已保留为退款处理中,可安全重试") + return + } + a.audit(r, "refund_requested", "order", orderID, map[string]any{"userId": userID, "amountCent": amountCent}) + reply(w, map[string]any{"success": true, "status": "REFUNDING"}) +} + +func grantOrderMembershipTx(ctx context.Context, tx *sql.Tx, orderID, userID, planID int64, durationDays, level int) error { + var base time.Time + if err := tx.QueryRowContext(ctx, `SELECT GREATEST(NOW(3),COALESCE(MAX(expires_at),NOW(3))) FROM subscriptions WHERE user_id=? AND status=1 AND expires_at>NOW(3)`, userID).Scan(&base); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(?,?,?,1,NOW(3),DATE_ADD(?,INTERVAL ? DAY))`, userID, planID, fmt.Sprintf("order:%d", orderID), base, durationDays); err != nil { + return err + } + _, err := tx.ExecContext(ctx, `UPDATE user_profiles SET is_vip=1,vip_level=GREATEST(vip_level,?) WHERE user_id=?`, level, userID) + return err +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/public_auth.go b/im/.deploy/application-20260831-120612/backend/internal/app/public_auth.go new file mode 100644 index 0000000..22c0321 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/public_auth.go @@ -0,0 +1,528 @@ +package app + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/binary" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type authRequest struct { + Phone string `json:"phone"` + Password string `json:"password"` + Code string `json:"code"` + Nickname string `json:"nickname"` + DeviceID string `json:"deviceId"` + Scene string `json:"scene"` +} + +func (a *App) sendSMS(w http.ResponseWriter, r *http.Request) { + var req authRequest + if err := decode(r, &req); err != nil || !validPhone(req.Phone) { + fail(w, http.StatusBadRequest, 20001, "请输入正确的手机号") + return + } + if !a.configBool(r.Context(), "sms.enabled", true) { + fail(w, http.StatusServiceUnavailable, 50002, "短信服务暂未开放") + return + } + if req.Scene == "" { + req.Scene = "login" + } + if req.Scene != "login" && req.Scene != "register" && req.Scene != "reset" && req.Scene != "change_phone" { + fail(w, http.StatusBadRequest, 20001, "验证码场景无效") + return + } + phone := strings.TrimSpace(req.Phone) + if !a.rateLimit(w, r, "sms_ip", clientIP(r), 20, time.Hour) || !a.rateLimit(w, r, "sms_phone", phone, 5, time.Hour) { + return + } + var recent int + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM sms_verification_codes WHERE phone_hash=? AND scene=? AND created_at>DATE_SUB(NOW(3),INTERVAL 60 SECOND)`, phoneHash(req.Phone), req.Scene).Scan(&recent) + if recent > 0 { + fail(w, http.StatusTooManyRequests, 20002, "请稍后再获取验证码") + return + } + provider := a.configPlain(r.Context(), "sms.provider", "debug") + if a.config.Environment == "production" && provider == "debug" { + fail(w, http.StatusServiceUnavailable, 50002, "生产环境禁止使用调试短信服务") + return + } + code := a.configPlain(r.Context(), "sms.debug_code", "123456") + if provider != "debug" { + var buffer [4]byte + _, _ = rand.Read(buffer[:]) + code = fmt.Sprintf("%06d", binary.BigEndian.Uint32(buffer[:])%1_000_000) + } + if err := a.dispatchSMS(r.Context(), phone, req.Scene, code); err != nil { + fail(w, http.StatusBadGateway, 50002, err.Error()) + return + } + expires, _ := strconv.Atoi(a.configPlain(r.Context(), "sms.expire_seconds", "300")) + if expires < 60 || expires > 1800 { + expires = 300 + } + codeHash := sha256.Sum256([]byte(code)) + _, err := a.db.ExecContext(r.Context(), `INSERT INTO sms_verification_codes(phone_hash,scene,code_hash,expires_at)VALUES(?,?,?,DATE_ADD(NOW(3),INTERVAL ? SECOND))`, phoneHash(req.Phone), req.Scene, codeHash[:], expires) + if err != nil { + fail(w, 500, 50001, "保存验证码失败") + return + } + data := map[string]any{"expiresIn": expires, "provider": provider} + if provider == "debug" { + data["debugCode"] = code + } + reply(w, data) +} + +func (a *App) register(w http.ResponseWriter, r *http.Request) { + var req authRequest + if err := decode(r, &req); err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + if !validPhone(req.Phone) || !validUserPassword(req.Password) || strings.TrimSpace(req.Nickname) == "" || len([]rune(strings.TrimSpace(req.Nickname))) > 50 { + fail(w, http.StatusBadRequest, 20001, "请填写有效的手机号、昵称和密码") + return + } + if !a.rateLimit(w, r, "register_ip", clientIP(r), 20, 10*time.Minute) || !a.rateLimit(w, r, "register_phone", strings.TrimSpace(req.Phone), 10, 10*time.Minute) { + return + } + if !a.consumeSMSCode(r, req.Phone, "register", req.Code) { + fail(w, http.StatusBadRequest, 20001, "验证码错误或已过期") + return + } + hash, err := hashPassword(req.Password) + if err != nil { + fail(w, 500, 50001, "创建账号失败") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "创建账号失败") + return + } + defer func() { _ = tx.Rollback() }() + publicID := fmt.Sprintf("XY%d%s", time.Now().UnixMilli(), randomToken()[:5]) + phoneCipher, encryptErr := a.encryptPhone(req.Phone) + if encryptErr != nil { + fail(w, 500, 50001, "加密账号信息失败") + return + } + result, err := tx.ExecContext(r.Context(), `INSERT INTO users (public_id,country_code,phone_hash,phone_cipher,password_hash) VALUES (?,'+86',?,?,?)`, publicID, phoneHash(req.Phone), phoneCipher, hash) + if err != nil { + fail(w, http.StatusConflict, 20001, "该手机号已注册") + return + } + userID, err := result.LastInsertId() + if err != nil { + fail(w, 500, 50001, "创建账号失败") + return + } + _, err = tx.ExecContext(r.Context(), `INSERT INTO user_profiles (user_id,nickname,bio,profile_score,last_active_at) VALUES (?,?, '遇见更好的陌生人',30,NOW(3))`, userID, req.Nickname) + if err != nil { + fail(w, 500, 50001, "创建资料失败") + return + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_privacy_settings (user_id) VALUES (?)`, userID); err != nil { + fail(w, 500, 50001, "创建账号失败") + return + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_notification_settings (user_id) VALUES (?)`, userID); err != nil { + fail(w, 500, 50001, "创建账号失败") + return + } + if err := tx.Commit(); err != nil { + fail(w, 500, 50001, "创建账号失败") + return + } + a.finishLogin(w, r, userID, req.Nickname, req.DeviceID) +} + +func (a *App) loginPassword(w http.ResponseWriter, r *http.Request) { + var req authRequest + if err := decode(r, &req); err != nil { + fail(w, 400, 20001, err.Error()) + return + } + if !validPhone(req.Phone) || req.Password == "" { + fail(w, http.StatusUnauthorized, 10001, "手机号或密码错误") + return + } + if !a.rateLimit(w, r, "login_ip", clientIP(r), 60, 10*time.Minute) || !a.rateLimit(w, r, "login_phone", strings.TrimSpace(req.Phone), 10, 10*time.Minute) { + return + } + var id int64 + var hash, nickname string + var status int + err := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.password_hash,u.status,p.nickname FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.phone_hash=? AND u.deleted_at IS NULL`, phoneHash(req.Phone)).Scan(&id, &hash, &status, &nickname) + if err != nil || !checkPassword(hash, req.Password) { + fail(w, http.StatusUnauthorized, 10001, "手机号或密码错误") + return + } + if status != 1 { + fail(w, http.StatusForbidden, 10006, "账号当前不可用") + return + } + a.finishLogin(w, r, id, nickname, req.DeviceID) +} + +func (a *App) loginSMS(w http.ResponseWriter, r *http.Request) { + var req authRequest + if err := decode(r, &req); err != nil || !validPhone(req.Phone) || len(req.Code) != 6 { + fail(w, 400, 20001, "验证码格式错误") + return + } + if !a.rateLimit(w, r, "sms_login_ip", clientIP(r), 30, 10*time.Minute) || !a.rateLimit(w, r, "sms_login_phone", strings.TrimSpace(req.Phone), 10, 10*time.Minute) { + return + } + if !a.consumeSMSCode(r, req.Phone, "login", req.Code) { + fail(w, 400, 20001, "验证码错误或已过期") + return + } + var id int64 + var nickname string + if err := a.db.QueryRowContext(r.Context(), `SELECT u.id,p.nickname FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.phone_hash=? AND u.status=1`, phoneHash(req.Phone)).Scan(&id, &nickname); err != nil { + fail(w, http.StatusUnauthorized, 10001, "账号不存在") + return + } + a.finishLogin(w, r, id, nickname, req.DeviceID) +} + +func (a *App) resetPassword(w http.ResponseWriter, r *http.Request) { + var req authRequest + if err := decode(r, &req); err != nil || !validPhone(req.Phone) || len(req.Code) != 6 || !validUserPassword(req.Password) { + fail(w, 400, 20001, "请填写有效的手机号、验证码和新密码") + return + } + if !a.rateLimit(w, r, "password_reset_ip", clientIP(r), 20, 10*time.Minute) || !a.rateLimit(w, r, "password_reset_phone", strings.TrimSpace(req.Phone), 10, 10*time.Minute) { + return + } + if !a.consumeSMSCode(r, req.Phone, "reset", req.Code) { + fail(w, 400, 20001, "验证码错误或已过期") + return + } + hash, err := hashPassword(req.Password) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "密码加密失败") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE users SET password_hash=? WHERE phone_hash=? AND deleted_at IS NULL`, hash, phoneHash(req.Phone)) + if err != nil { + fail(w, 500, 50001, "重置密码失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 404, 30001, "账号不存在") + return + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id IN (SELECT id FROM users WHERE phone_hash=?) AND revoked_at IS NULL`, phoneHash(req.Phone)) + _, _ = a.db.ExecContext(r.Context(), `INSERT INTO user_security_controls(user_id,token_version,force_logout_at,password_reset_at) SELECT id,1,NOW(3),NOW(3) FROM users WHERE phone_hash=? ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=VALUES(force_logout_at),password_reset_at=VALUES(password_reset_at)`, phoneHash(req.Phone)) + var resetUserID int64 + if a.db.QueryRowContext(r.Context(), `SELECT id FROM users WHERE phone_hash=?`, phoneHash(req.Phone)).Scan(&resetUserID) == nil { + a.hub.disconnect(resetUserID) + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) consumeSMSCode(r *http.Request, phone, scene, code string) bool { + if phone == "" || code == "" { + return false + } + var id int64 + var expected []byte + err := a.db.QueryRowContext(r.Context(), `SELECT id,code_hash FROM sms_verification_codes WHERE phone_hash=? AND scene=? AND used_at IS NULL AND expires_at>NOW(3) ORDER BY id DESC LIMIT 1`, phoneHash(phone), scene).Scan(&id, &expected) + if err != nil { + return false + } + actual := sha256.Sum256([]byte(code)) + if !bytesEqual(expected, actual[:]) { + return false + } + result, err := a.db.ExecContext(r.Context(), `UPDATE sms_verification_codes SET used_at=NOW(3) WHERE id=? AND used_at IS NULL`, id) + if err != nil { + return false + } + affected, _ := result.RowsAffected() + return affected == 1 +} + +func bytesEqual(left, right []byte) bool { + if len(left) != len(right) { + return false + } + var different byte + for index := range left { + different |= left[index] ^ right[index] + } + return different == 0 +} + +func (a *App) finishLogin(w http.ResponseWriter, r *http.Request, id int64, nickname, deviceID string) { + if deviceID == "" { + deviceID = "web-h5" + } + if len(deviceID) > 100 { + fail(w, http.StatusBadRequest, 20001, "设备标识过长") + return + } + accessToken, err := a.token(id, "user", nickname, 30*time.Minute) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建登录令牌失败") + return + } + refresh := randomToken() + refreshHash := sha256.Sum256([]byte(refresh)) + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建登录会话失败") + return + } + defer func() { _ = tx.Rollback() }() + if _, err = tx.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND device_id=? AND revoked_at IS NULL`, id, deviceID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新设备会话失败") + return + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_sessions (user_id,device_id,refresh_token_hash,expires_at) VALUES (?,?,?,DATE_ADD(NOW(3), INTERVAL 60 DAY))`, id, deviceID, refreshHash[:]); err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建登录会话失败") + return + } + platform := strings.TrimSpace(r.Header.Get("X-Device-Platform")) + model := strings.TrimSpace(r.Header.Get("X-Device-Model")) + osVersion := strings.TrimSpace(r.Header.Get("X-OS-Version")) + if decoded, decodeErr := url.QueryUnescape(model); decodeErr == nil { + model = decoded + } + if decoded, decodeErr := url.QueryUnescape(osVersion); decodeErr == nil { + osVersion = decoded + } + appVersion := strings.TrimSpace(r.Header.Get("X-App-Version")) + if platform == "" { + platform = "unknown" + } + if len(platform) > 20 || len(model) > 100 || len(osVersion) > 50 || len(appVersion) > 30 { + fail(w, http.StatusBadRequest, 20001, "设备信息格式错误") + return + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_devices(user_id,device_id,platform,device_model,os_version,app_version,last_ip,last_active_at,status) + VALUES(?,?,?,?,?,?,?,NOW(3),1) + ON DUPLICATE KEY UPDATE platform=VALUES(platform),device_model=VALUES(device_model),os_version=VALUES(os_version),app_version=VALUES(app_version),last_ip=VALUES(last_ip),last_active_at=NOW(3),status=1`, id, deviceID, platform, model, osVersion, appVersion, clientIP(r)); err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存设备信息失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET last_active_at=NOW(3) WHERE user_id=?`, id); err != nil || tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "保存登录状态失败") + return + } + reply(w, map[string]any{"accessToken": accessToken, "refreshToken": refresh, "expiresIn": 1800, "userId": id}) +} + +func (a *App) refreshToken(w http.ResponseWriter, r *http.Request) { + var req struct { + RefreshToken string `json:"refreshToken"` + } + if err := decode(r, &req); err != nil { + fail(w, 400, 20001, "refreshToken required") + return + } + oldHash := sha256.Sum256([]byte(req.RefreshToken)) + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "刷新登录状态失败") + return + } + defer func() { _ = tx.Rollback() }() + var sessionID, id int64 + var nickname, deviceID string + err = tx.QueryRowContext(r.Context(), `SELECT s.id,s.user_id,p.nickname,s.device_id FROM user_sessions s JOIN users u ON u.id=s.user_id JOIN user_profiles p ON p.user_id=s.user_id WHERE s.refresh_token_hash=? AND s.revoked_at IS NULL AND s.expires_at>NOW(3) AND u.status=1 AND u.deleted_at IS NULL FOR UPDATE`, oldHash[:]).Scan(&sessionID, &id, &nickname, &deviceID) + if err != nil { + fail(w, 401, 10001, "刷新令牌无效") + return + } + accessToken, err := a.token(id, "user", nickname, 30*time.Minute) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建登录令牌失败") + return + } + newRefresh := randomToken() + newHash := sha256.Sum256([]byte(newRefresh)) + result, err := tx.ExecContext(r.Context(), `UPDATE user_sessions SET refresh_token_hash=?,last_active_at=NOW(3) WHERE id=? AND refresh_token_hash=? AND revoked_at IS NULL`, newHash[:], sessionID, oldHash[:]) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "刷新登录状态失败") + return + } + affected, _ := result.RowsAffected() + if affected != 1 || tx.Commit() != nil { + fail(w, http.StatusUnauthorized, 10001, "刷新令牌已被使用") + return + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE user_devices SET last_active_at=NOW(3),last_ip=? WHERE user_id=? AND device_id=?`, clientIP(r), id, deviceID) + reply(w, map[string]any{"accessToken": accessToken, "refreshToken": newRefresh, "expiresIn": 1800}) +} + +func (a *App) logout(w http.ResponseWriter, r *http.Request) { + who := current(r) + var req struct { + RefreshToken string `json:"refreshToken"` + } + if r.Body != nil && r.ContentLength != 0 { + _ = decode(r, &req) + } + if strings.TrimSpace(req.RefreshToken) != "" { + hash := sha256.Sum256([]byte(strings.TrimSpace(req.RefreshToken))) + _, _ = a.db.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND refresh_token_hash=? AND revoked_at IS NULL`, who.ID, hash[:]) + } else { + _, _ = a.db.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, who.ID) + } + _, _ = a.db.ExecContext(r.Context(), `INSERT INTO user_security_controls(user_id,token_version,force_logout_at) VALUES(?,1,NOW(3)) ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=VALUES(force_logout_at)`, who.ID) + a.hub.disconnect(who.ID) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) me(w http.ResponseWriter, r *http.Request) { + profile, err := a.loadProfile(r, current(r).ID, current(r).ID) + if err != nil { + fail(w, 404, 30001, "用户不存在") + return + } + reply(w, profile) +} + +func (a *App) updateProfile(w http.ResponseWriter, r *http.Request) { + var req struct { + Nickname *string `json:"nickname"` + Avatar *string `json:"avatar"` + Cover *string `json:"cover"` + Bio *string `json:"bio"` + City *string `json:"city"` + Birthday *string `json:"birthday"` + Occupation *string `json:"occupation"` + Height *int `json:"height"` + Gender *int `json:"gender"` + Education *int `json:"education"` + RelationshipStatus *int `json:"relationshipStatus"` + TagIDs *[]int64 `json:"tagIds"` + } + if err := decode(r, &req); err != nil { + fail(w, 400, 20001, err.Error()) + return + } + who := current(r) + var nickname, avatar, cover, bio, city, occupation string + var birthday sql.NullString + var height sql.NullInt64 + var gender, education, relationship int + if err := a.db.QueryRowContext(r.Context(), `SELECT nickname,avatar_url,cover_url,bio,city_name,occupation,DATE_FORMAT(birthday,'%Y-%m-%d'),height_cm,gender,education,relationship_status FROM user_profiles WHERE user_id=?`, who.ID).Scan(&nickname, &avatar, &cover, &bio, &city, &occupation, &birthday, &height, &gender, &education, &relationship); err != nil { + fail(w, 500, 50001, "读取资料失败") + return + } + if req.Nickname != nil { + nickname = strings.TrimSpace(*req.Nickname) + } + if req.Avatar != nil { + avatar = strings.TrimSpace(*req.Avatar) + } + if req.Cover != nil { + cover = strings.TrimSpace(*req.Cover) + } + if req.Bio != nil { + bio = strings.TrimSpace(*req.Bio) + } + if req.City != nil { + city = strings.TrimSpace(*req.City) + } + if req.Occupation != nil { + occupation = strings.TrimSpace(*req.Occupation) + } + if req.Height != nil { + if *req.Height < 0 || *req.Height > 260 { + fail(w, 400, 20001, "身高范围无效") + return + } + height = sql.NullInt64{Int64: int64(*req.Height), Valid: *req.Height > 0} + } + if req.Gender != nil { + if *req.Gender < 0 || *req.Gender > 2 { + fail(w, 400, 20001, "性别选项无效") + return + } + gender = *req.Gender + } + if req.Education != nil { + if *req.Education < 0 || *req.Education > 10 { + fail(w, 400, 20001, "学历选项无效") + return + } + education = *req.Education + } + if req.RelationshipStatus != nil { + if *req.RelationshipStatus < 0 || *req.RelationshipStatus > 10 { + fail(w, 400, 20001, "情感状态无效") + return + } + relationship = *req.RelationshipStatus + } + if nickname == "" || len([]rune(nickname)) > 50 || len([]rune(bio)) > 500 || len([]rune(city)) > 50 || len([]rune(occupation)) > 100 { + fail(w, 400, 20001, "资料内容长度无效") + return + } + if req.Birthday != nil { + value := strings.TrimSpace(*req.Birthday) + if value == "" { + birthday = sql.NullString{} + } else { + parsed, parseErr := time.Parse("2006-01-02", value) + if parseErr != nil || parsed.After(time.Now().AddDate(-18, 0, 0)) || parsed.Before(time.Now().AddDate(-100, 0, 0)) { + fail(w, 400, 20001, "仅支持 18-100 周岁的生日日期") + return + } + birthday = sql.NullString{String: value, Valid: true} + } + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + defer func() { _ = tx.Rollback() }() + _, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET nickname=?,avatar_url=?,cover_url=?,bio=?,city_name=?,occupation=?,birthday=?,height_cm=?,gender=?,education=?,relationship_status=?,profile_score=GREATEST(profile_score,80) WHERE user_id=?`, nickname, avatar, cover, bio, city, occupation, birthday, height, gender, education, relationship, who.ID) + if err == nil && req.TagIDs != nil { + if len(*req.TagIDs) > 12 { + fail(w, 400, 20001, "最多选择 12 个标签") + return + } + _, err = tx.ExecContext(r.Context(), `DELETE FROM user_tags WHERE user_id=?`, who.ID) + for _, tagID := range *req.TagIDs { + if err != nil { + break + } + result, insertErr := tx.ExecContext(r.Context(), `INSERT INTO user_tags(user_id,tag_id) SELECT ?,id FROM tags WHERE id=? AND status=1`, who.ID, tagID) + err = insertErr + if err == nil { + affected, _ := result.RowsAffected() + if affected == 0 { + err = fmt.Errorf("标签不存在") + } + } + } + } + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "保存失败") + return + } + a.me(w, r) +} + +func nullableString(v sql.NullString) string { + if v.Valid { + return v.String + } + return "" +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/rate_limit.go b/im/.deploy/application-20260831-120612/backend/internal/app/rate_limit.go new file mode 100644 index 0000000..71e57a7 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/rate_limit.go @@ -0,0 +1,56 @@ +package app + +import ( + "context" + "crypto/sha256" + "fmt" + "net" + "net/http" + "strings" + "time" +) + +func (a *App) allowRequest(ctx context.Context, action, subject string, limit int, window time.Duration) bool { + if limit < 1 || window < time.Second { + return false + } + windowSeconds := int64(window / time.Second) + slot := time.Now().Unix() / windowSeconds + key := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d", action, subject, slot))) + expiresAt := time.Unix((slot+1)*windowSeconds, 0).Add(time.Minute) + _, err := a.db.ExecContext(ctx, `INSERT INTO api_rate_limits(bucket_key,action_name,hits,expires_at) VALUES(?,?,1,?) ON DUPLICATE KEY UPDATE hits=hits+1,expires_at=VALUES(expires_at)`, key[:], action, expiresAt) + if err != nil { + return false + } + var hits int + if err = a.db.QueryRowContext(ctx, `SELECT hits FROM api_rate_limits WHERE bucket_key=?`, key[:]).Scan(&hits); err != nil { + return false + } + if key[0] == 0 { + _, _ = a.db.ExecContext(ctx, `DELETE FROM api_rate_limits WHERE expires_at 255 { + t.Fatal("long passwords require unique salts and a hash that fits existing storage") + } + if !checkPassword(first, password) || checkPassword(first, legacyPassword+"b") || checkPassword(first, legacyPassword) { + t.Fatal("the entire long password must participate in verification") + } + for _, invalid := range []string{"", "plaintext", "$argon2id$", strings.Replace(first, "m=19456", "m=999999999", 1), first[:len(first)-1], longPasswordHashPrefix + strings.Repeat("!", 66)} { + if checkPassword(invalid, password) { + t.Fatal("malformed hash was accepted") + } + } +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/seed.go b/im/.deploy/application-20260831-120612/backend/internal/app/seed.go new file mode 100644 index 0000000..22b8771 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/seed.go @@ -0,0 +1,191 @@ +package app + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/json" + "fmt" + "time" +) + +type demoUser struct { + Phone, Nickname, Avatar, Cover, City, Bio string + Gender, Age, VIP int + Lat, Lng float64 +} + +var demoUsers = []demoUser{ + {"13800138000", "小甜心", "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=600&auto=format&fit=crop", "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200&auto=format&fit=crop", "上海", "热爱生活,喜欢记录美好瞬间", 2, 23, 2, 31.2304, 121.4737}, + {"13800138001", "小鹿心", "https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=600&auto=format&fit=crop", "https://images.unsplash.com/photo-1519608487953-e999c86e7455?w=1200&auto=format&fit=crop", "上海", "摄影、旅行和一切浪漫的事", 2, 23, 1, 31.2310, 121.4750}, + {"13800138002", "爱笑的眼睛", "https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=600&auto=format&fit=crop", "https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=1200&auto=format&fit=crop", "上海", "愿每一天都有新的故事", 2, 24, 1, 31.2289, 121.4701}, + {"13800138003", "一只可爱喵", "https://images.unsplash.com/photo-1517841905240-472988babdf9?w=600&auto=format&fit=crop", "https://images.unsplash.com/photo-1497436072909-f5e4be1713c0?w=1200&auto=format&fit=crop", "上海", "咖啡重度爱好者", 2, 23, 0, 31.2260, 121.4690}, + {"13800138004", "星辰大海", "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=600&auto=format&fit=crop", "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?w=1200&auto=format&fit=crop", "上海", "周末去爬山吧", 1, 25, 0, 31.2248, 121.4810}, + {"13800138005", "南音不渝", "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=600&auto=format&fit=crop", "https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?w=1200&auto=format&fit=crop", "上海", "听歌、跑步、看展", 1, 24, 0, 31.2204, 121.4760}, + {"13800138006", "温柔的风", "https://images.unsplash.com/photo-1531123897727-8f129e1688ce?w=600&auto=format&fit=crop", "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=1200&auto=format&fit=crop", "上海", "想遇见同频的人", 2, 24, 1, 31.2184, 121.4860}, + {"13800138007", "月亮邮递员", "https://images.unsplash.com/photo-1531746020798-e6953c6e8e04?w=600&auto=format&fit=crop", "https://images.unsplash.com/photo-1470252649378-9c29740c9fa8?w=1200&auto=format&fit=crop", "上海", "收集晚霞和好心情", 2, 24, 1, 31.2154, 121.4710}, +} + +func (a *App) Seed() error { + var tableCount int + if err := a.db.QueryRow(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'users'`).Scan(&tableCount); err != nil { + return err + } + if tableCount == 0 { + return fmt.Errorf("数据库尚未初始化,请先运行 scripts/migrate.ps1") + } + if err := a.encryptLegacyPhones(context.Background()); err != nil { + return fmt.Errorf("encrypt legacy phone data: %w", err) + } + + adminUsername := a.config.BootstrapAdminUsername + adminPassword := a.config.BootstrapAdminPassword + adminRealName := a.config.BootstrapAdminRealName + if a.config.SeedDemo && adminPassword == "" { + adminUsername = "admin" + adminPassword = "Admin@123" + } + if adminPassword != "" { + if adminUsername == "" { + return fmt.Errorf("已配置管理员密码,但 IM_BOOTSTRAP_ADMIN_USERNAME 为空") + } + adminHash, hashErr := hashPassword(adminPassword) + if hashErr != nil { + return hashErr + } + _, err := a.db.Exec(`INSERT INTO admin_users (username,password_hash,real_name,avatar_url,status) + VALUES (?,?,?,?,1) ON DUPLICATE KEY UPDATE real_name=VALUES(real_name)`, adminUsername, adminHash, adminRealName, demoUsers[0].Avatar) + if err != nil { + return err + } + _, err = a.db.Exec(`INSERT IGNORE INTO admin_user_roles(admin_user_id,role_id) + SELECT a.id,r.id FROM admin_users a JOIN admin_roles r ON r.role_code='super_admin' WHERE a.username=?`, adminUsername) + if err != nil { + return fmt.Errorf("assign bootstrap administrator role: %w", err) + } + } + var adminCount int + if err := a.db.QueryRow(`SELECT COUNT(*) FROM admin_users WHERE status=1`).Scan(&adminCount); err != nil { + return err + } + if adminCount == 0 { + return fmt.Errorf("没有可用管理员,请配置 IM_BOOTSTRAP_ADMIN_USERNAME 和 IM_BOOTSTRAP_ADMIN_PASSWORD") + } + if !a.config.SeedDemo { + return nil + } + + var users int + if err := a.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&users); err != nil { + return err + } + if users > 0 { + return nil + } + + passwordHash, err := hashPassword("123456") + if err != nil { + return err + } + tx, err := a.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + ids := make([]int64, 0, len(demoUsers)) + for index, item := range demoUsers { + birthday := time.Now().AddDate(-item.Age, 0, 0).Format("2006-01-02") + phoneCipher, encryptErr := a.encryptPhone(item.Phone) + if encryptErr != nil { + return encryptErr + } + result, execErr := tx.Exec(`INSERT INTO users (public_id,country_code,phone_hash,phone_cipher,password_hash,status,risk_level) + VALUES (?,?,?,?,?,1,0)`, fmt.Sprintf("XY%08d", index+10001), "+86", phoneHash(item.Phone), phoneCipher, passwordHash) + if execErr != nil { + return execErr + } + id, _ := result.LastInsertId() + ids = append(ids, id) + _, execErr = tx.Exec(`INSERT INTO user_profiles + (user_id,nickname,avatar_url,cover_url,gender,birthday,height_cm,city_code,city_name,occupation,bio,profile_score,is_vip,vip_level,last_active_at) + VALUES (?,?,?,?,?,?,?,?,'上海',?,?,95,?,?,?)`, id, item.Nickname, item.Avatar, item.Cover, item.Gender, birthday, 163+index%12, "310100", "创意行业", item.Bio, btoi(item.VIP > 0), item.VIP, time.Now().Add(-time.Duration(index*4)*time.Minute)) + if execErr != nil { + return execErr + } + _, _ = tx.Exec(`INSERT INTO user_privacy_settings (user_id) VALUES (?)`, id) + _, _ = tx.Exec(`INSERT INTO user_location_states (user_id,city_code,location_cell,latitude,longitude,source) VALUES (?,'310100','wx4g',?,?,'seed')`, id, item.Lat, item.Lng) + _, _ = tx.Exec(`INSERT INTO user_risk_profiles (user_id,risk_score,risk_level) VALUES (?, ?, ?)`, id, index*3, btoi(index == 7)) + } + + mediaSets := [][]string{ + {"https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=900&auto=format&fit=crop", "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=900&auto=format&fit=crop", "https://images.unsplash.com/photo-1470770841072-f978cf4d019e?w=900&auto=format&fit=crop"}, + {"https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?w=900&auto=format&fit=crop", "https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?w=900&auto=format&fit=crop", "https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=900&auto=format&fit=crop"}, + {"https://images.unsplash.com/photo-1470252649378-9c29740c9fa8?w=900&auto=format&fit=crop"}, + } + contents := []string{"今天的天空很美,心情也很好~", "周末去爬山啦", "晚霞也太治愈了吧"} + for index, content := range contents { + result, execErr := tx.Exec(`INSERT INTO posts (user_id,content,city_code,location_text,like_count,comment_count) VALUES (?,?, '310100','上海',?,?)`, ids[index+1], content, 23+index*13, 8+index*2) + if execErr != nil { + return execErr + } + postID, _ := result.LastInsertId() + for order, url := range mediaSets[index] { + _, _ = tx.Exec(`INSERT INTO post_media (post_id,media_url,media_type,sort_order) VALUES (?,?,'image',?)`, postID, url, order) + } + } + + for i := 1; i < len(ids); i++ { + _, _ = tx.Exec(`INSERT INTO user_follows (user_id,target_user_id) VALUES (?,?)`, ids[0], ids[i]) + if i < 5 { + _, _ = tx.Exec(`INSERT INTO user_likes (user_id,target_user_id,source) VALUES (?,?, 'seed')`, ids[0], ids[i]) + } + } + + conversationResult, err := tx.Exec(`INSERT INTO im_conversations (conversation_type,last_seq,last_message_at) VALUES (1,3,NOW(3))`) + if err != nil { + return err + } + conversationID, _ := conversationResult.LastInsertId() + _, _ = tx.Exec(`INSERT INTO im_direct_conversations (conversation_id,user1_id,user2_id) VALUES (?,?,?)`, conversationID, ids[0], ids[1]) + _, _ = tx.Exec(`INSERT INTO im_conversation_members (conversation_id,user_id,read_seq,delivered_seq) VALUES (?,?,3,3),(?,?,1,3)`, conversationID, ids[0], conversationID, ids[1]) + messages := []struct { + sender int64 + text string + }{{ids[1], "今天的晚霞好美呀~"}, {ids[0], "阳光正好,想和你去看一次日落"}, {ids[1], "好呀好呀,我也正想去看呢!"}} + for index, message := range messages { + body, _ := json.Marshal(map[string]string{"text": message.text}) + result, execErr := tx.Exec(`INSERT INTO im_messages (conversation_id,seq,sender_id,client_msg_id,message_type,body) VALUES (?,?,?,?,1,?)`, conversationID, index+1, message.sender, fmt.Sprintf("01JDEMO%019d", index+1), body) + if execErr != nil { + return execErr + } + if index == len(messages)-1 { + messageID, _ := result.LastInsertId() + _, _ = tx.Exec(`UPDATE im_conversations SET last_message_id=? WHERE id=?`, messageID, conversationID) + } + } + + _, _ = tx.Exec(`INSERT INTO notifications (user_id,type,title,content,biz_type,biz_id) VALUES + (?,'follow','新的关注','爱笑的眼睛关注了你','user',?), + (?,'like','新的喜欢','小鹿心喜欢了你','user',?), + (?,'system','欢迎来到星遇','完善资料可以获得更多推荐','',NULL)`, ids[0], ids[2], ids[0], ids[1], ids[0]) + _, _ = tx.Exec(`INSERT INTO reports (reporter_user_id,target_type,target_id,reason_code,description,status) VALUES (?, 'user', ?, 'advertising', '频繁发送广告链接', 'PENDING')`, ids[2], ids[7]) + _, _ = tx.Exec(`INSERT INTO risk_events (user_id,event_type,score_delta,device_id,ip,metadata) VALUES (?, 'rapid_messages', 12, 'demo-device', '127.0.0.1', JSON_OBJECT('count', 32))`, ids[7]) + _, _ = tx.Exec(`INSERT INTO orders (order_no,user_id,product_type,product_id,amount_cent,status,channel,paid_at) VALUES ('XYDEMO202608240001', ?, 'membership', 2, 6800, 'PAID', 'alipay', NOW(3))`, ids[0]) + return tx.Commit() +} + +func btoi(value bool) int { + if value { + return 1 + } + return 0 +} + +func scanNullableString(value sql.NullString) string { + if value.Valid { + return value.String + } + return "" +} + +func sha(value string) []byte { sum := sha256.Sum256([]byte(value)); return sum[:] } diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/sms_providers.go b/im/.deploy/application-20260831-120612/backend/internal/app/sms_providers.go new file mode 100644 index 0000000..be4f49a --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/sms_providers.go @@ -0,0 +1,416 @@ +package app + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const smsResponseLimit = 256 << 10 + +type smsDeliveryResult struct { + MessageID string +} + +func parseSMSProviderEndpoint(provider, raw string) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf("%s API 地址必须是无账号、查询参数和片段的有效 HTTPS 地址", cloudSMSProviderName(provider)) + } + path := strings.TrimRight(parsed.EscapedPath(), "/") + switch provider { + case "aliyun", "tencent": + if path != "" { + return nil, fmt.Errorf("%s API 地址不能包含路径", cloudSMSProviderName(provider)) + } + case "huawei": + if !strings.HasSuffix(path, "/sms/batchSendSms/v1") { + return nil, fmt.Errorf("华为云 APP 接入地址必须包含 /sms/batchSendSms/v1") + } + } + return parsed, nil +} + +func (a *App) validateSMSProviderConfig(ctx context.Context) error { + provider := a.configPlain(ctx, "sms.provider", "debug") + if !containsString([]string{"aliyun", "tencent", "huawei", "webhook", "debug"}, provider) { + return fmt.Errorf("不支持的短信厂商 %q", provider) + } + if a.config.Environment == "production" && provider == "debug" { + return fmt.Errorf("生产环境禁止使用 debug 短信提供商") + } + for _, spec := range integrationSpecs["sms"] { + if !spec.Required || (len(spec.Providers) > 0 && !containsString(spec.Providers, provider)) { + continue + } + if strings.TrimSpace(a.configPlain(ctx, spec.Key, "")) == "" { + return fmt.Errorf("请填写%s", spec.Label) + } + } + + switch provider { + case "aliyun", "tencent", "huawei": + endpoint := a.configPlain(ctx, "sms."+provider+".endpoint", "") + if _, err := parseSMSProviderEndpoint(provider, endpoint); err != nil { + return err + } + if _, err := a.smsTemplateParams(ctx, provider, "000000"); err != nil { + return err + } + case "webhook": + endpoint := a.configPlain(ctx, "sms.webhook_url", "") + if a.config.Environment == "production" && !validHTTPSURL(endpoint) { + return fmt.Errorf("生产环境 Webhook 地址必须使用 HTTPS") + } + } + return nil +} + +func cloudSMSProviderName(provider string) string { + switch provider { + case "aliyun": + return "阿里云" + case "tencent": + return "腾讯云" + case "huawei": + return "华为云" + case "webhook": + return "Webhook" + case "debug": + return "本地调试" + default: + return provider + } +} + +func (a *App) smsTemplateID(ctx context.Context, provider, scene string) (string, error) { + if !containsString([]string{"register", "login", "reset"}, scene) { + return "", fmt.Errorf("短信验证码场景无效") + } + key := "sms." + provider + ".template_" + scene + if provider == "webhook" { + key = "sms.template_" + scene + } + value := strings.TrimSpace(a.configPlain(ctx, key, "")) + if value == "" { + return "", fmt.Errorf("%s%s模板未配置", cloudSMSProviderName(provider), scene) + } + return value, nil +} + +func (a *App) smsTemplateParams(ctx context.Context, provider, code string) (string, error) { + raw := a.configPlain(ctx, "sms."+provider+".template_params", "") + expires, _ := strconv.Atoi(a.configPlain(ctx, "sms.expire_seconds", "300")) + return renderSMSTemplateParams(provider, raw, code, expires) +} + +func renderSMSTemplateParams(provider, raw, code string, expires int) (string, error) { + if expires < 60 || expires > 1800 { + expires = 300 + } + minutes := (expires + 59) / 60 + rendered := strings.ReplaceAll(raw, "{{code}}", code) + rendered = strings.ReplaceAll(rendered, "{{minutes}}", strconv.Itoa(minutes)) + if provider == "aliyun" { + var object map[string]any + if json.Unmarshal([]byte(rendered), &object) != nil || len(object) == 0 { + return "", fmt.Errorf("阿里云模板变量必须是有效的非空 JSON 对象") + } + payload, _ := json.Marshal(object) + return string(payload), nil + } + var values []string + if json.Unmarshal([]byte(rendered), &values) != nil || len(values) == 0 { + return "", fmt.Errorf("%s模板参数必须是有效的非空 JSON 字符串数组", cloudSMSProviderName(provider)) + } + payload, _ := json.Marshal(values) + return string(payload), nil +} + +func huaweiWSSE(appKey, appSecret, nonce, created string) (string, string) { + digestHash := sha256.Sum256([]byte(nonce + created + appSecret)) + passwordDigest := base64.StdEncoding.EncodeToString(digestHash[:]) + authorization := `WSSE realm="SDP",profile="UsernameToken",type="Appkey"` + wsse := `UsernameToken Username="` + appKey + `",PasswordDigest="` + passwordDigest + `",Nonce="` + nonce + `",Created="` + created + `"` + return authorization, wsse +} + +func smsHTTPClient() *http.Client { + return &http.Client{Timeout: 8 * time.Second} +} + +func readSMSResponse(response *http.Response) ([]byte, error) { + body, err := io.ReadAll(io.LimitReader(response.Body, smsResponseLimit)) + if err != nil { + return nil, err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("短信厂商返回 HTTP %d", response.StatusCode) + } + return body, nil +} + +func providerError(provider, code, message string) error { + message = strings.TrimSpace(message) + if len(message) > 300 { + message = message[:300] + } + if message == "" { + message = "请求失败" + } + return fmt.Errorf("%s短信发送失败 [%s]: %s", cloudSMSProviderName(provider), code, message) +} + +func (a *App) sendAliyunSMS(ctx context.Context, phone, scene, code string) (smsDeliveryResult, error) { + endpoint := a.configPlain(ctx, "sms.aliyun.endpoint", "https://dysmsapi.aliyuncs.com") + parsed, err := parseSMSProviderEndpoint("aliyun", endpoint) + if err != nil { + return smsDeliveryResult{}, err + } + templateID, err := a.smsTemplateID(ctx, "aliyun", scene) + if err != nil { + return smsDeliveryResult{}, err + } + templateParams, err := a.smsTemplateParams(ctx, "aliyun", code) + if err != nil { + return smsDeliveryResult{}, err + } + query := url.Values{ + "PhoneNumbers": {phone}, + "SignName": {a.configPlain(ctx, "sms.aliyun.sign_name", "")}, + "TemplateCode": {templateID}, + "TemplateParam": {templateParams}, + } + canonicalQuery := strings.ReplaceAll(query.Encode(), "+", "%20") + parsed.RawQuery = canonicalQuery + canonicalURI := "/" + now := time.Now().UTC().Format("2006-01-02T15:04:05Z") + nonce := randomToken()[:32] + emptyHash := sha256.Sum256(nil) + payloadHash := hex.EncodeToString(emptyHash[:]) + canonicalHeaders := "host:" + parsed.Host + "\n" + + "x-acs-action:SendSms\n" + + "x-acs-content-sha256:" + payloadHash + "\n" + + "x-acs-date:" + now + "\n" + + "x-acs-signature-nonce:" + nonce + "\n" + + "x-acs-version:2017-05-25\n" + signedHeaders := "host;x-acs-action;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version" + canonicalRequest := "POST\n" + canonicalURI + "\n" + canonicalQuery + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + payloadHash + requestHash := sha256.Sum256([]byte(canonicalRequest)) + stringToSign := "ACS3-HMAC-SHA256\n" + hex.EncodeToString(requestHash[:]) + accessKeyID := a.configPlain(ctx, "sms.aliyun.access_key_id", "") + mac := hmac.New(sha256.New, []byte(a.configPlain(ctx, "sms.aliyun.access_key_secret", ""))) + _, _ = mac.Write([]byte(stringToSign)) + authorization := "ACS3-HMAC-SHA256 Credential=" + accessKeyID + ",SignedHeaders=" + signedHeaders + ",Signature=" + hex.EncodeToString(mac.Sum(nil)) + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), nil) + if err != nil { + return smsDeliveryResult{}, err + } + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", authorization) + request.Header.Set("x-acs-action", "SendSms") + request.Header.Set("x-acs-content-sha256", payloadHash) + request.Header.Set("x-acs-date", now) + request.Header.Set("x-acs-signature-nonce", nonce) + request.Header.Set("x-acs-version", "2017-05-25") + response, err := smsHTTPClient().Do(request) + if err != nil { + return smsDeliveryResult{}, fmt.Errorf("阿里云短信连接失败: %w", err) + } + defer response.Body.Close() + body, err := readSMSResponse(response) + if err != nil { + return smsDeliveryResult{}, err + } + var result struct { + BizID string `json:"BizId"` + Code string `json:"Code"` + Message string `json:"Message"` + RequestID string `json:"RequestId"` + } + if json.Unmarshal(body, &result) != nil { + return smsDeliveryResult{}, fmt.Errorf("阿里云短信返回无效 JSON") + } + if result.Code != "OK" { + return smsDeliveryResult{}, providerError("aliyun", result.Code, result.Message) + } + return smsDeliveryResult{MessageID: result.BizID}, nil +} + +func hmacSHA256(key, value []byte) []byte { + mac := hmac.New(sha256.New, key) + _, _ = mac.Write(value) + return mac.Sum(nil) +} + +func (a *App) sendTencentSMS(ctx context.Context, phone, scene, code string) (smsDeliveryResult, error) { + endpoint := a.configPlain(ctx, "sms.tencent.endpoint", "https://sms.tencentcloudapi.com") + parsed, err := parseSMSProviderEndpoint("tencent", endpoint) + if err != nil { + return smsDeliveryResult{}, err + } + templateID, err := a.smsTemplateID(ctx, "tencent", scene) + if err != nil { + return smsDeliveryResult{}, err + } + paramsJSON, err := a.smsTemplateParams(ctx, "tencent", code) + if err != nil { + return smsDeliveryResult{}, err + } + var params []string + _ = json.Unmarshal([]byte(paramsJSON), ¶ms) + payload, _ := json.Marshal(map[string]any{ + "PhoneNumberSet": []string{"+86" + phone}, + "SignName": a.configPlain(ctx, "sms.tencent.sign_name", ""), + "SmsSdkAppId": a.configPlain(ctx, "sms.tencent.sdk_app_id", ""), + "TemplateId": templateID, + "TemplateParamSet": params, + }) + timestamp := time.Now().Unix() + date := time.Unix(timestamp, 0).UTC().Format("2006-01-02") + contentType := "application/json; charset=utf-8" + canonicalHeaders := "content-type:" + contentType + "\nhost:" + parsed.Host + "\n" + signedHeaders := "content-type;host" + payloadHash := sha256.Sum256(payload) + canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hex.EncodeToString(payloadHash[:]) + canonicalHash := sha256.Sum256([]byte(canonicalRequest)) + credentialScope := date + "/sms/tc3_request" + stringToSign := "TC3-HMAC-SHA256\n" + strconv.FormatInt(timestamp, 10) + "\n" + credentialScope + "\n" + hex.EncodeToString(canonicalHash[:]) + secretKey := a.configPlain(ctx, "sms.tencent.secret_key", "") + secretDate := hmacSHA256([]byte("TC3"+secretKey), []byte(date)) + secretService := hmacSHA256(secretDate, []byte("sms")) + secretSigning := hmacSHA256(secretService, []byte("tc3_request")) + signature := hex.EncodeToString(hmacSHA256(secretSigning, []byte(stringToSign))) + authorization := "TC3-HMAC-SHA256 Credential=" + a.configPlain(ctx, "sms.tencent.secret_id", "") + "/" + credentialScope + ", SignedHeaders=" + signedHeaders + ", Signature=" + signature + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload)) + if err != nil { + return smsDeliveryResult{}, err + } + request.Header.Set("Authorization", authorization) + request.Header.Set("Content-Type", contentType) + request.Header.Set("X-TC-Action", "SendSms") + request.Header.Set("X-TC-Version", "2021-01-11") + request.Header.Set("X-TC-Timestamp", strconv.FormatInt(timestamp, 10)) + request.Header.Set("X-TC-Region", a.configPlain(ctx, "sms.tencent.region", "ap-guangzhou")) + response, err := smsHTTPClient().Do(request) + if err != nil { + return smsDeliveryResult{}, fmt.Errorf("腾讯云短信连接失败: %w", err) + } + defer response.Body.Close() + body, err := readSMSResponse(response) + if err != nil { + return smsDeliveryResult{}, err + } + var result struct { + Response struct { + Error *struct { + Code string `json:"Code"` + Message string `json:"Message"` + } `json:"Error"` + RequestID string `json:"RequestId"` + SendStatusSet []struct { + Code string `json:"Code"` + Message string `json:"Message"` + SerialNo string `json:"SerialNo"` + } `json:"SendStatusSet"` + } `json:"Response"` + } + if json.Unmarshal(body, &result) != nil { + return smsDeliveryResult{}, fmt.Errorf("腾讯云短信返回无效 JSON") + } + if result.Response.Error != nil { + return smsDeliveryResult{}, providerError("tencent", result.Response.Error.Code, result.Response.Error.Message) + } + if len(result.Response.SendStatusSet) == 0 { + return smsDeliveryResult{}, fmt.Errorf("腾讯云短信未返回发送状态") + } + status := result.Response.SendStatusSet[0] + if !strings.EqualFold(status.Code, "Ok") { + return smsDeliveryResult{}, providerError("tencent", status.Code, status.Message) + } + return smsDeliveryResult{MessageID: status.SerialNo}, nil +} + +func (a *App) sendHuaweiSMS(ctx context.Context, phone, scene, code string) (smsDeliveryResult, error) { + endpoint := a.configPlain(ctx, "sms.huawei.endpoint", "") + parsed, err := parseSMSProviderEndpoint("huawei", endpoint) + if err != nil { + return smsDeliveryResult{}, err + } + templateID, err := a.smsTemplateID(ctx, "huawei", scene) + if err != nil { + return smsDeliveryResult{}, err + } + paramsJSON, err := a.smsTemplateParams(ctx, "huawei", code) + if err != nil { + return smsDeliveryResult{}, err + } + appKey := a.configPlain(ctx, "sms.huawei.app_key", "") + nonce := randomToken()[:32] + created := time.Now().UTC().Format("2006-01-02T15:04:05Z") + authorization, wsse := huaweiWSSE(appKey, a.configPlain(ctx, "sms.huawei.app_secret", ""), nonce, created) + form := url.Values{ + "from": {a.configPlain(ctx, "sms.huawei.sender", "")}, + "to": {"+86" + phone}, + "templateId": {templateID}, + "templateParas": {paramsJSON}, + } + if signature := strings.TrimSpace(a.configPlain(ctx, "sms.huawei.signature", "")); signature != "" { + form.Set("signature", signature) + } + if callback := strings.TrimSpace(a.configPlain(ctx, "sms.huawei.status_callback", "")); callback != "" { + form.Set("statusCallback", callback) + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), strings.NewReader(form.Encode())) + if err != nil { + return smsDeliveryResult{}, err + } + request.Header.Set("Accept", "application/json") + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Authorization", authorization) + request.Header.Set("X-WSSE", wsse) + response, err := smsHTTPClient().Do(request) + if err != nil { + return smsDeliveryResult{}, fmt.Errorf("华为云短信连接失败: %w", err) + } + defer response.Body.Close() + body, err := readSMSResponse(response) + if err != nil { + return smsDeliveryResult{}, err + } + var result struct { + Code string `json:"code"` + Description string `json:"description"` + Result []struct { + MessageID string `json:"smsMsgId"` + Status string `json:"status"` + } `json:"result"` + } + if json.Unmarshal(body, &result) != nil { + return smsDeliveryResult{}, fmt.Errorf("华为云短信返回无效 JSON") + } + if result.Code != "000000" { + return smsDeliveryResult{}, providerError("huawei", result.Code, result.Description) + } + if len(result.Result) == 0 || result.Result[0].Status != "000000" { + status := "EMPTY_RESULT" + if len(result.Result) > 0 { + status = result.Result[0].Status + } + return smsDeliveryResult{}, providerError("huawei", status, "短信未被平台接受") + } + return smsDeliveryResult{MessageID: result.Result[0].MessageID}, nil +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/sms_providers_test.go b/im/.deploy/application-20260831-120612/backend/internal/app/sms_providers_test.go new file mode 100644 index 0000000..9971b59 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/sms_providers_test.go @@ -0,0 +1,52 @@ +package app + +import ( + "strings" + "testing" +) + +func TestRenderSMSTemplateParams(t *testing.T) { + aliyun, err := renderSMSTemplateParams("aliyun", `{"code":"{{code}}","minutes":"{{minutes}}"}`, "086421", 301) + if err != nil || aliyun != `{"code":"086421","minutes":"6"}` { + t.Fatalf("unexpected aliyun template parameters: %q, %v", aliyun, err) + } + tencent, err := renderSMSTemplateParams("tencent", `["{{code}}","{{minutes}}"]`, "086421", 300) + if err != nil || tencent != `["086421","5"]` { + t.Fatalf("unexpected tencent template parameters: %q, %v", tencent, err) + } + if _, err = renderSMSTemplateParams("huawei", `{"code":"{{code}}"}`, "086421", 300); err == nil { + t.Fatal("huawei parameters must reject a non-array JSON value") + } +} + +func TestHuaweiWSSE(t *testing.T) { + authorization, wsse := huaweiWSSE("app-key", "secret", "abc", "2026-08-25T10:00:00Z") + if authorization != `WSSE realm="SDP",profile="UsernameToken",type="Appkey"` { + t.Fatalf("unexpected authorization header: %s", authorization) + } + if !strings.Contains(wsse, `Username="app-key"`) || !strings.Contains(wsse, `PasswordDigest="imMh4+lxH6z6wYNFDE3+ycvpRGbC7S/R2Q7ehhnXEqU="`) { + t.Fatalf("unexpected X-WSSE header: %s", wsse) + } +} + +func TestParseSMSProviderEndpoint(t *testing.T) { + tests := []struct { + provider string + endpoint string + valid bool + }{ + {provider: "aliyun", endpoint: "https://dysmsapi.aliyuncs.com", valid: true}, + {provider: "tencent", endpoint: "https://sms.tencentcloudapi.com/", valid: true}, + {provider: "huawei", endpoint: "https://smsapi.cn-north-4.myhuaweicloud.com:443/sms/batchSendSms/v1", valid: true}, + {provider: "aliyun", endpoint: "http://dysmsapi.aliyuncs.com", valid: false}, + {provider: "tencent", endpoint: "https://sms.tencentcloudapi.com/custom", valid: false}, + {provider: "huawei", endpoint: "https://user:pass@example.com/sms/batchSendSms/v1", valid: false}, + {provider: "huawei", endpoint: "https://example.com/sms/batchSendSms/v1?token=secret", valid: false}, + } + for _, test := range tests { + _, err := parseSMSProviderEndpoint(test.provider, test.endpoint) + if (err == nil) != test.valid { + t.Errorf("parseSMSProviderEndpoint(%q, %q) error = %v, valid = %v", test.provider, test.endpoint, err, test.valid) + } + } +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/social.go b/im/.deploy/application-20260831-120612/backend/internal/app/social.go new file mode 100644 index 0000000..7e4761f --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/social.go @@ -0,0 +1,658 @@ +package app + +import ( + "database/sql" + "encoding/json" + "fmt" + "math" + "net/http" + "strconv" + "strings" + "time" +) + +type profileView struct { + ID int64 `json:"id"` + PublicID string `json:"publicId"` + IsTest bool `json:"isTest"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Cover string `json:"cover"` + Gender int `json:"gender"` + Age int `json:"age"` + Height int `json:"height"` + City string `json:"city"` + Birthday string `json:"birthday"` + Occupation string `json:"occupation"` + Education int `json:"education"` + RelationshipStatus int `json:"relationshipStatus"` + Bio string `json:"bio"` + VIP bool `json:"vip"` + VIPLevel int `json:"vipLevel"` + Online bool `json:"online"` + LastActiveAt *time.Time `json:"lastActiveAt,omitempty"` + Distance float64 `json:"distance"` + DistanceText string `json:"distanceText"` + FollowingCount int `json:"followingCount"` + FollowerCount int `json:"followerCount"` + PostCount int `json:"postCount"` + LikeCount int `json:"likeCount"` + Following bool `json:"following"` + Liked bool `json:"liked"` + Tags []string `json:"tags"` +} + +func (a *App) loadProfile(r *http.Request, id, viewerID int64) (profileView, error) { + var item profileView + var birthday sql.NullTime + var active sql.NullTime + var vip int + var onlineVisible, lastActiveVisible int + err := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.public_id,u.is_test,p.nickname,p.avatar_url,p.cover_url,p.gender,p.birthday,COALESCE(p.height_cm,0),p.city_name,p.occupation,p.education,p.relationship_status,p.bio,p.is_vip,p.vip_level,p.last_active_at,privacy.online_visible,privacy.last_active_visible, + (SELECT COUNT(*) FROM user_follows WHERE user_id=u.id),(SELECT COUNT(*) FROM user_follows WHERE target_user_id=u.id),(SELECT COUNT(*) FROM posts WHERE user_id=u.id AND status=1),(SELECT COUNT(*) FROM post_likes pl JOIN posts po ON po.id=pl.post_id WHERE po.user_id=u.id), + EXISTS(SELECT 1 FROM user_follows WHERE user_id=? AND target_user_id=u.id),EXISTS(SELECT 1 FROM user_likes WHERE user_id=? AND target_user_id=u.id) + FROM users u JOIN user_profiles p ON p.user_id=u.id JOIN user_privacy_settings privacy ON privacy.user_id=u.id + WHERE u.id=? AND u.status=1 AND (u.id=? OR NOT EXISTS(SELECT 1 FROM user_blocks blocked WHERE (blocked.user_id=? AND blocked.blocked_user_id=u.id) OR (blocked.user_id=u.id AND blocked.blocked_user_id=?)))`, viewerID, viewerID, id, viewerID, viewerID, viewerID).Scan( + &item.ID, &item.PublicID, &item.IsTest, &item.Nickname, &item.Avatar, &item.Cover, &item.Gender, &birthday, &item.Height, &item.City, &item.Occupation, &item.Education, &item.RelationshipStatus, &item.Bio, &vip, &item.VIPLevel, &active, &onlineVisible, &lastActiveVisible, &item.FollowingCount, &item.FollowerCount, &item.PostCount, &item.LikeCount, &item.Following, &item.Liked) + if err != nil { + return item, err + } + item.VIP = vip == 1 + if birthday.Valid { + item.Age = age(birthday.Time) + item.Birthday = birthday.Time.Format("2006-01-02") + } + item.Online = (id == viewerID || onlineVisible == 1) && active.Valid && time.Since(active.Time) < 15*time.Minute + if active.Valid && (id == viewerID || lastActiveVisible == 1) { + activeTime := active.Time + item.LastActiveAt = &activeTime + } + item.Tags = []string{} + tagRows, tagErr := a.db.QueryContext(r.Context(), `SELECT t.name FROM user_tags ut JOIN tags t ON t.id=ut.tag_id WHERE ut.user_id=? AND t.status=1 ORDER BY t.sort_order,t.id LIMIT 12`, id) + if tagErr == nil { + defer tagRows.Close() + for tagRows.Next() { + var name string + if tagRows.Scan(&name) == nil { + item.Tags = append(item.Tags, name) + } + } + } + return item, nil +} + +func (a *App) userProfile(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "invalid id") + return + } + who := current(r) + if id != who.ID { + var targetAllows, viewerInvisible int + _ = a.db.QueryRowContext(r.Context(), `SELECT allow_profile_visit_record FROM user_privacy_settings WHERE user_id=?`, id).Scan(&targetAllows) + _ = a.db.QueryRowContext(r.Context(), `SELECT invisible_visit FROM user_privacy_settings WHERE user_id=?`, who.ID).Scan(&viewerInvisible) + canHide := viewerInvisible == 1 && a.resolveMembershipEntitlements(r.Context(), who.ID).CanInvisibleVisit + if targetAllows == 1 && !canHide { + _, _ = a.db.ExecContext(r.Context(), `INSERT INTO profile_visits (viewer_user_id,target_user_id,source) VALUES (?,?,'profile')`, who.ID, id) + } + } + item, err := a.loadProfile(r, id, who.ID) + if err != nil { + fail(w, 404, 30001, "用户不存在") + return + } + reply(w, item) +} + +func (a *App) discover(w http.ResponseWriter, r *http.Request) { a.discoverList(w, r, false) } +func (a *App) nearby(w http.ResponseWriter, r *http.Request) { a.discoverList(w, r, true) } + +func (a *App) discoverList(w http.ResponseWriter, r *http.Request, byDistance bool) { + who := current(r) + gender, _ := strconv.Atoi(r.URL.Query().Get("gender")) + scope := r.URL.Query().Get("scope") + sortMode := r.URL.Query().Get("sort") + page, pageSize, offset := pageOptions(r) + var myLat, myLng sql.NullFloat64 + _ = a.db.QueryRowContext(r.Context(), `SELECT latitude,longitude FROM user_location_states WHERE user_id=?`, who.ID).Scan(&myLat, &myLng) + fromWhere := ` FROM users u JOIN user_profiles p ON p.user_id=u.id JOIN user_privacy_settings privacy ON privacy.user_id=u.id LEFT JOIN user_location_states l ON l.user_id=u.id + WHERE u.status=1 AND u.id<>? AND NOT EXISTS(SELECT 1 FROM user_blocks b WHERE (b.user_id=? AND b.blocked_user_id=u.id) OR (b.user_id=u.id AND b.blocked_user_id=?))` + filterArgs := []any{who.ID, who.ID, who.ID} + if scope == "following" { + fromWhere += ` AND EXISTS(SELECT 1 FROM user_follows mine WHERE mine.user_id=? AND mine.target_user_id=u.id)` + filterArgs = append(filterArgs, who.ID) + } + if gender > 0 { + fromWhere += ` AND p.gender=?` + filterArgs = append(filterArgs, gender) + } + if byDistance { + fromWhere += ` AND privacy.nearby_visible=1` + } + var total int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*)`+fromWhere, filterArgs...).Scan(&total); err != nil { + fail(w, 500, 50001, "查询推荐用户失败") + return + } + query := `SELECT u.id,u.public_id,u.is_test,p.nickname,p.avatar_url,p.cover_url,p.gender,p.birthday,COALESCE(p.height_cm,0),p.city_name,p.bio,p.is_vip,p.vip_level,p.last_active_at,l.latitude,l.longitude,privacy.distance_visible,privacy.online_visible,privacy.last_active_visible, + EXISTS(SELECT 1 FROM user_follows f WHERE f.user_id=? AND f.target_user_id=u.id),EXISTS(SELECT 1 FROM user_likes x WHERE x.user_id=? AND x.target_user_id=u.id) + ` + fromWhere + args := append([]any{who.ID, who.ID}, filterArgs...) + if byDistance && myLat.Valid && myLng.Valid { + // Sort before LIMIT/OFFSET so page two can never contain users closer + // than page one. Hidden or missing distances are placed last. + query += ` ORDER BY CASE WHEN privacy.distance_visible=1 THEN COALESCE(ST_Distance_Sphere(POINT(l.longitude,l.latitude),POINT(?,?)),1000000000000000) ELSE 1000000000000000 END ASC,p.last_active_at DESC,u.id DESC LIMIT ? OFFSET ?` + args = append(args, myLng.Float64, myLat.Float64) + } else if sortMode == "latest" || byDistance { + query += ` ORDER BY p.last_active_at DESC,u.id DESC LIMIT ? OFFSET ?` + } else { + query += ` ORDER BY COALESCE((SELECT MAX(plan.recommendation_weight) FROM subscriptions sub JOIN membership_plans plan ON plan.id=sub.plan_id WHERE sub.user_id=u.id AND sub.status=1 AND sub.started_at<=NOW(3) AND sub.expires_at>NOW(3)),0) DESC,p.last_active_at DESC LIMIT ? OFFSET ?` + } + args = append(args, pageSize, offset) + rows, err := a.db.QueryContext(r.Context(), query, args...) + if err != nil { + fail(w, 500, 50001, err.Error()) + return + } + defer rows.Close() + items := []profileView{} + for rows.Next() { + var item profileView + var birthday sql.NullTime + var active sql.NullTime + var lat, lng sql.NullFloat64 + var distanceVisible, onlineVisible, lastActiveVisible int + var vip int + if err := rows.Scan(&item.ID, &item.PublicID, &item.IsTest, &item.Nickname, &item.Avatar, &item.Cover, &item.Gender, &birthday, &item.Height, &item.City, &item.Bio, &vip, &item.VIPLevel, &active, &lat, &lng, &distanceVisible, &onlineVisible, &lastActiveVisible, &item.Following, &item.Liked); err != nil { + continue + } + item.VIP = vip == 1 + if birthday.Valid { + item.Age = age(birthday.Time) + } + item.Online = onlineVisible == 1 && active.Valid && time.Since(active.Time) < 15*time.Minute + if active.Valid && lastActiveVisible == 1 { + activeTime := active.Time + item.LastActiveAt = &activeTime + } + if distanceVisible == 1 && myLat.Valid && myLng.Valid && lat.Valid && lng.Valid { + item.Distance = haversine(myLat.Float64, myLng.Float64, lat.Float64, lng.Float64) + item.DistanceText = distanceText(item.Distance) + } + item.Tags = []string{} + items = append(items, item) + } + reply(w, map[string]any{"items": items, "total": total, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) +} + +func (a *App) updateLocation(w http.ResponseWriter, r *http.Request) { + var req struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + CityCode string `json:"cityCode"` + } + if err := decode(r, &req); err != nil || req.Latitude < -90 || req.Latitude > 90 || req.Longitude < -180 || req.Longitude > 180 { + fail(w, 400, 20001, "无效的位置") + return + } + if req.CityCode == "" { + req.CityCode = "310100" + } + _, err := a.db.ExecContext(r.Context(), `INSERT INTO user_location_states (user_id,city_code,location_cell,latitude,longitude,source) VALUES (?,?, 'wx4g',?,?,'gps') ON DUPLICATE KEY UPDATE city_code=VALUES(city_code),latitude=VALUES(latitude),longitude=VALUES(longitude),last_location_at=NOW(3),source='gps'`, current(r).ID, req.CityCode, req.Latitude, req.Longitude) + if err != nil { + fail(w, 500, 50001, "位置更新失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) follow(w http.ResponseWriter, r *http.Request) { + a.relationship(w, r, "user_follows", true) +} +func (a *App) unfollow(w http.ResponseWriter, r *http.Request) { + a.relationship(w, r, "user_follows", false) +} +func (a *App) likeUser(w http.ResponseWriter, r *http.Request) { + a.relationship(w, r, "user_likes", true) +} +func (a *App) unlikeUser(w http.ResponseWriter, r *http.Request) { + a.relationship(w, r, "user_likes", false) +} + +func (a *App) relationship(w http.ResponseWriter, r *http.Request, table string, create bool) { + target, err := pathID(r) + if err != nil || target == current(r).ID { + fail(w, 400, 20001, "无效用户") + return + } + if create { + var targetExists int + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM users u WHERE u.id=? AND u.status=1 AND u.deleted_at IS NULL AND NOT EXISTS(SELECT 1 FROM user_blocks b WHERE (b.user_id=? AND b.blocked_user_id=u.id) OR (b.user_id=u.id AND b.blocked_user_id=?)))`, target, current(r).ID, current(r).ID).Scan(&targetExists) + if targetExists != 1 { + fail(w, http.StatusNotFound, 30001, "用户不存在或不可操作") + return + } + if table == "user_likes" { + tx, beginErr := a.db.BeginTx(r.Context(), nil) + if beginErr != nil { + fail(w, 500, 50001, "操作失败") + return + } + defer func() { _ = tx.Rollback() }() + var exists int + _ = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM user_likes WHERE user_id=? AND target_user_id=?)`, current(r).ID, target).Scan(&exists) + if exists == 0 { + err = a.reserveDailyLike(tx, r, "user", target) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT IGNORE INTO user_likes (user_id,target_user_id,source) VALUES (?,?,'profile')`, current(r).ID, target) + } + if err == nil { + err = tx.Commit() + } + if limitErr, ok := err.(*dailyLikeLimitError); ok { + fail(w, http.StatusTooManyRequests, 20002, limitErr.Error()) + return + } + } else { + _, err = a.db.ExecContext(r.Context(), `INSERT IGNORE INTO user_follows (user_id,target_user_id) VALUES (?,?)`, current(r).ID, target) + } + } else { + _, err = a.db.ExecContext(r.Context(), `DELETE FROM `+table+` WHERE user_id=? AND target_user_id=?`, current(r).ID, target) + } + if err != nil { + fail(w, 500, 50001, "操作失败") + return + } + if create { + typ, title, content := "follow", "新的关注", current(r).Name+" 关注了你" + if table == "user_likes" { + typ, title, content = "like", "新的喜欢", current(r).Name+" 喜欢了你" + } + a.notifyUser(r.Context(), target, typ, title, content, "user", current(r).ID) + } + reply(w, map[string]bool{"success": true}) +} + +type postView struct { + ID int64 `json:"id"` + User profileView `json:"user"` + Content string `json:"content"` + Location string `json:"location"` + LikeCount int `json:"likeCount"` + CommentCount int `json:"commentCount"` + Liked bool `json:"liked"` + Media []string `json:"media"` + CreatedAt time.Time `json:"createdAt"` + CanEdit bool `json:"canEdit"` + CanDelete bool `json:"canDelete"` +} + +func (a *App) feed(w http.ResponseWriter, r *http.Request) { + who := current(r) + page, pageSize, offset := pageOptions(r) + scopeFollowing := r.URL.Query().Get("scope") == "following" + countQuery := `SELECT COUNT(*) FROM posts p JOIN users u ON u.id=p.user_id JOIN user_profiles pr ON pr.user_id=p.user_id WHERE p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id)))` + countArgs := []any{who.ID, who.ID} + if scopeFollowing { + countQuery += ` AND EXISTS(SELECT 1 FROM user_follows f WHERE f.user_id=? AND f.target_user_id=p.user_id)` + countArgs = append(countArgs, who.ID) + } + var total int + if err := a.db.QueryRowContext(r.Context(), countQuery, countArgs...).Scan(&total); err != nil { + fail(w, 500, 50001, "查询动态失败") + return + } + query := `SELECT p.id,p.user_id,p.content,p.location_text,p.like_count,p.comment_count,p.created_at,u.public_id,pr.nickname,pr.avatar_url,pr.gender,pr.is_vip,EXISTS(SELECT 1 FROM post_likes l WHERE l.post_id=p.id AND l.user_id=?) FROM posts p JOIN users u ON u.id=p.user_id JOIN user_profiles pr ON pr.user_id=p.user_id WHERE p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id)))` + args := []any{who.ID, who.ID, who.ID} + if scopeFollowing { + query += ` AND EXISTS(SELECT 1 FROM user_follows f WHERE f.user_id=? AND f.target_user_id=p.user_id)` + args = append(args, who.ID) + } + query += ` ORDER BY p.created_at DESC LIMIT ? OFFSET ?` + args = append(args, pageSize, offset) + rows, err := a.db.QueryContext(r.Context(), query, args...) + if err != nil { + fail(w, 500, 50001, err.Error()) + return + } + defer rows.Close() + items := []postView{} + for rows.Next() { + var item postView + var uid int64 + var vip int + if rows.Scan(&item.ID, &uid, &item.Content, &item.Location, &item.LikeCount, &item.CommentCount, &item.CreatedAt, &item.User.PublicID, &item.User.Nickname, &item.User.Avatar, &item.User.Gender, &vip, &item.Liked) != nil { + continue + } + item.User.ID = uid + item.User.VIP = vip == 1 + item.CanEdit = uid == who.ID + item.CanDelete = uid == who.ID + mediaRows, _ := a.db.QueryContext(r.Context(), `SELECT media_url FROM post_media WHERE post_id=? ORDER BY sort_order`, item.ID) + item.Media = []string{} + if mediaRows != nil { + for mediaRows.Next() { + var url string + _ = mediaRows.Scan(&url) + item.Media = append(item.Media, url) + } + _ = mediaRows.Close() + } + items = append(items, item) + } + reply(w, map[string]any{"items": items, "total": total, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) +} + +func (a *App) createPost(w http.ResponseWriter, r *http.Request) { + if a.isSanctionActive(r.Context(), current(r).ID, "CONTENT_LIMIT") { + fail(w, http.StatusForbidden, 10006, "账号处于内容发布限制期") + return + } + var req struct { + Content string `json:"content"` + Media []string `json:"media"` + Location string `json:"location"` + Visibility int `json:"visibility"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "动态格式错误") + return + } + req.Content = strings.TrimSpace(req.Content) + req.Location = strings.TrimSpace(req.Location) + if (req.Content == "" && len(req.Media) == 0) || len([]rune(req.Content)) > 2000 || len(req.Media) > 9 || len([]rune(req.Location)) > 100 { + fail(w, 400, 20001, "动态需包含文字或图片,最多 9 张图片") + return + } + if req.Visibility != 2 { + req.Visibility = 1 + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "发布失败") + return + } + defer func() { _ = tx.Rollback() }() + for _, rawURL := range req.Media { + mediaURL := strings.TrimSpace(rawURL) + var exists int + if mediaURL == "" || tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM media_assets WHERE owner_user_id=? AND public_url=? AND status=1 AND moderation_status=1)`, current(r).ID, mediaURL).Scan(&exists) != nil || exists != 1 { + fail(w, 400, 20001, "动态图片必须由当前账号上传且通过安全检查") + return + } + } + result, err := tx.ExecContext(r.Context(), `INSERT INTO posts (user_id,content,visibility,city_code,location_text) VALUES (?,?,?,'310100',?)`, current(r).ID, req.Content, req.Visibility, req.Location) + if err != nil { + _ = tx.Rollback() + fail(w, 500, 50001, "发布失败") + return + } + id, _ := result.LastInsertId() + for i, url := range req.Media { + if _, err = tx.ExecContext(r.Context(), `INSERT INTO post_media(post_id,media_url,sort_order)VALUES(?,?,?)`, id, url, i); err != nil { + fail(w, 500, 50001, "发布失败") + return + } + } + if tx.Commit() != nil { + fail(w, 500, 50001, "发布失败") + return + } + reply(w, map[string]any{"id": id}) +} + +func (a *App) likePost(w http.ResponseWriter, r *http.Request) { a.postLike(w, r, true) } +func (a *App) unlikePost(w http.ResponseWriter, r *http.Request) { a.postLike(w, r, false) } +func (a *App) postLike(w http.ResponseWriter, r *http.Request, create bool) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "invalid id") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "点赞失败") + return + } + defer func() { _ = tx.Rollback() }() + var postExists int + if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM posts p WHERE p.id=? AND p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id))))`, id, current(r).ID, current(r).ID).Scan(&postExists); err != nil || postExists != 1 { + fail(w, http.StatusNotFound, 30001, "动态不存在") + return + } + if create { + var exists int + _ = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM post_likes WHERE post_id=? AND user_id=?)`, id, current(r).ID).Scan(&exists) + if exists == 0 { + if reserveErr := a.reserveDailyLike(tx, r, "post", id); reserveErr != nil { + _ = tx.Rollback() + if limitErr, ok := reserveErr.(*dailyLikeLimitError); ok { + fail(w, http.StatusTooManyRequests, 20002, limitErr.Error()) + return + } + fail(w, 500, 50001, "点赞失败") + return + } + } + result, execErr := tx.ExecContext(r.Context(), `INSERT IGNORE INTO post_likes(post_id,user_id)VALUES(?,?)`, id, current(r).ID) + if execErr != nil { + fail(w, 500, 50001, "点赞失败") + return + } + affected, _ := result.RowsAffected() + if affected > 0 { + if _, err = tx.ExecContext(r.Context(), `UPDATE posts SET like_count=like_count+1 WHERE id=?`, id); err != nil { + fail(w, 500, 50001, "点赞失败") + return + } + _, _ = tx.ExecContext(r.Context(), `INSERT INTO notifications(user_id,type,title,content,biz_type,biz_id) SELECT p.user_id,'like','动态获赞',?,'post',p.id FROM posts p LEFT JOIN user_notification_settings ns ON ns.user_id=p.user_id WHERE p.id=? AND p.user_id<>? AND COALESCE(ns.interaction_enabled,1)=1`, current(r).Name+" 赞了你的动态", id, current(r).ID) + } + } else { + result, execErr := tx.ExecContext(r.Context(), `DELETE FROM post_likes WHERE post_id=? AND user_id=?`, id, current(r).ID) + if execErr != nil { + fail(w, 500, 50001, "取消点赞失败") + return + } + affected, _ := result.RowsAffected() + if affected > 0 { + if _, err = tx.ExecContext(r.Context(), `UPDATE posts SET like_count=GREATEST(like_count-1,0) WHERE id=?`, id); err != nil { + fail(w, 500, 50001, "取消点赞失败") + return + } + } + } + if err = tx.Commit(); err != nil { + fail(w, 500, 50001, "保存点赞状态失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) comments(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态编号无效") + return + } + if _, err = a.loadPost(r, id); err != nil { + fail(w, http.StatusNotFound, 30001, "动态不存在") + return + } + page, pageSize, offset := pageOptions(r) + var total int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM post_comments WHERE post_id=? AND status=1`, id).Scan(&total); err != nil { + fail(w, 500, 50001, "查询失败") + return + } + rows, err := a.db.QueryContext(r.Context(), `SELECT c.id,c.content,c.created_at,p.user_id,p.nickname,p.avatar_url,c.parent_comment_id,c.reply_user_id,COALESCE(reply.nickname,'') FROM post_comments c JOIN user_profiles p ON p.user_id=c.user_id LEFT JOIN user_profiles reply ON reply.user_id=c.reply_user_id WHERE c.post_id=? AND c.status=1 ORDER BY c.created_at LIMIT ? OFFSET ?`, id, pageSize, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var cid, uid int64 + var content, nick, avatar, replyNickname string + var parentID, replyUserID sql.NullInt64 + var created time.Time + _ = rows.Scan(&cid, &content, &created, &uid, &nick, &avatar, &parentID, &replyUserID, &replyNickname) + items = append(items, map[string]any{"id": cid, "content": content, "createdAt": created, "parentCommentId": nullableInt64(parentID), "replyUser": map[string]any{"id": nullableInt64(replyUserID), "nickname": replyNickname}, "canDelete": uid == current(r).ID, "user": map[string]any{"id": uid, "nickname": nick, "avatar": avatar}}) + } + reply(w, map[string]any{"items": items, "total": total, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) +} + +func (a *App) createComment(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态编号无效") + return + } + var req struct { + Content string `json:"content"` + ParentCommentID *int64 `json:"parentCommentId"` + ReplyUserID *int64 `json:"replyUserId"` + } + req.Content = strings.TrimSpace(req.Content) + if decode(r, &req) != nil { + fail(w, 400, 20001, "评论格式错误") + return + } + req.Content = strings.TrimSpace(req.Content) + if req.Content == "" || len([]rune(req.Content)) > 1000 { + fail(w, 400, 20001, "评论不能为空") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "评论失败") + return + } + defer func() { _ = tx.Rollback() }() + var postExists int + if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM posts p WHERE p.id=? AND p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id))))`, id, current(r).ID, current(r).ID).Scan(&postExists); err != nil || postExists != 1 { + fail(w, http.StatusNotFound, 30001, "动态不存在") + return + } + if req.ParentCommentID != nil { + var replyUserID int64 + if err := tx.QueryRowContext(r.Context(), `SELECT user_id FROM post_comments WHERE id=? AND post_id=? AND status=1`, *req.ParentCommentID, id).Scan(&replyUserID); err != nil { + _ = tx.Rollback() + fail(w, 400, 20001, "回复的评论不存在") + return + } + if req.ReplyUserID == nil { + req.ReplyUserID = &replyUserID + } + } + result, err := tx.ExecContext(r.Context(), `INSERT INTO post_comments(post_id,user_id,parent_comment_id,reply_user_id,content)VALUES(?,?,?,?,?)`, id, current(r).ID, req.ParentCommentID, req.ReplyUserID, req.Content) + if err != nil { + _ = tx.Rollback() + fail(w, 500, 50001, "评论失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE posts SET comment_count=comment_count+1 WHERE id=?`, id); err != nil { + fail(w, 500, 50001, "评论失败") + return + } + _, _ = tx.ExecContext(r.Context(), `INSERT INTO notifications(user_id,type,title,content,biz_type,biz_id) SELECT p.user_id,'comment','新的评论',?,'post',p.id FROM posts p LEFT JOIN user_notification_settings ns ON ns.user_id=p.user_id WHERE p.id=? AND p.user_id<>? AND COALESCE(ns.interaction_enabled,1)=1`, current(r).Name+" 评论了你的动态", id, current(r).ID) + if err = tx.Commit(); err != nil { + fail(w, 500, 50001, "评论失败") + return + } + cid, _ := result.LastInsertId() + reply(w, map[string]any{"id": cid}) +} + +func (a *App) createReport(w http.ResponseWriter, r *http.Request) { + var req struct { + TargetType string `json:"targetType"` + TargetID int64 `json:"targetId"` + Reason string `json:"reason"` + Description string `json:"description"` + Evidence []string `json:"evidence"` + } + if decode(r, &req) != nil || req.TargetID == 0 { + fail(w, 400, 20001, "举报信息不完整") + return + } + req.TargetType = strings.ToLower(strings.TrimSpace(req.TargetType)) + req.Reason = strings.ToLower(strings.TrimSpace(req.Reason)) + req.Description = strings.TrimSpace(req.Description) + allowedTargets := map[string]bool{"user": true, "post": true, "comment": true, "message": true} + allowedReasons := map[string]bool{"fraud": true, "harassment": true, "pornography": true, "advertising": true, "violence": true, "minor_safety": true, "privacy": true, "other": true} + if !allowedTargets[req.TargetType] || !allowedReasons[req.Reason] || len([]rune(req.Description)) > 1000 || len(req.Evidence) > 6 { + fail(w, 400, 20001, "举报类型、原因或证据无效") + return + } + var targetExists int + switch req.TargetType { + case "user": + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM users WHERE id=? AND id<>? AND status=1 AND deleted_at IS NULL)`, req.TargetID, current(r).ID).Scan(&targetExists) + case "post": + if item, loadErr := a.loadPost(r, req.TargetID); loadErr == nil && item.User.ID != current(r).ID { + targetExists = 1 + } + case "comment": + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM post_comments c JOIN posts p ON p.id=c.post_id WHERE c.id=? AND c.status=1 AND c.user_id<>? AND p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id))))`, req.TargetID, current(r).ID, current(r).ID, current(r).ID).Scan(&targetExists) + case "message": + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM im_messages message JOIN im_conversation_members member ON member.conversation_id=message.conversation_id AND member.user_id=? AND member.status=1 WHERE message.id=? AND message.sender_id<>?)`, current(r).ID, req.TargetID, current(r).ID).Scan(&targetExists) + } + if targetExists != 1 { + fail(w, http.StatusNotFound, 30001, "举报目标不存在、不可访问或属于当前账号") + return + } + evidence, evidenceErr := a.validateOwnedImageEvidence(r.Context(), current(r).ID, req.Evidence) + if evidenceErr != nil { + fail(w, http.StatusBadRequest, 20001, evidenceErr.Error()) + return + } + var duplicate int + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM reports WHERE reporter_user_id=? AND target_type=? AND target_id=? AND status='PENDING' AND created_at>DATE_SUB(NOW(3),INTERVAL 24 HOUR))`, current(r).ID, req.TargetType, req.TargetID).Scan(&duplicate) + if duplicate == 1 { + fail(w, http.StatusConflict, 20001, "该内容已举报,请等待处理") + return + } + evidenceJSON, _ := json.Marshal(evidence) + result, err := a.db.ExecContext(r.Context(), `INSERT INTO reports(reporter_user_id,target_type,target_id,reason_code,description,evidence_json)VALUES(?,?,?,?,?,?)`, current(r).ID, req.TargetType, req.TargetID, req.Reason, req.Description, evidenceJSON) + if err != nil { + fail(w, 500, 50001, "提交失败") + return + } + id, _ := result.LastInsertId() + reply(w, map[string]any{"id": id, "status": "PENDING"}) +} + +func age(birthday time.Time) int { + now := time.Now() + years := now.Year() - birthday.Year() + if now.YearDay() < birthday.YearDay() { + years-- + } + return years +} +func haversine(lat1, lng1, lat2, lng2 float64) float64 { + const earth = 6371 + dlat := (lat2 - lat1) * math.Pi / 180 + dlng := (lng2 - lng1) * math.Pi / 180 + a := math.Sin(dlat/2)*math.Sin(dlat/2) + math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Sin(dlng/2)*math.Sin(dlng/2) + return earth * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) +} +func distanceText(value float64) string { + if value < 1 { + return fmt.Sprintf("%.2fkm", value) + } + return fmt.Sprintf("%.1fkm", value) +} +func sortProfilesByDistance(items []profileView) { + for i := 0; i < len(items); i++ { + for j := i + 1; j < len(items); j++ { + if items[j].Distance < items[i].Distance { + items[i], items[j] = items[j], items[i] + } + } + } +} +func jsonBytes(value any) []byte { data, _ := json.Marshal(value); return data } diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/storage_providers.go b/im/.deploy/application-20260831-120612/backend/internal/app/storage_providers.go new file mode 100644 index 0000000..2089b48 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/storage_providers.go @@ -0,0 +1,258 @@ +package app + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + aliyunoss "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss" + aliyuncredentials "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials" + huaweiobs "github.com/huaweicloud/huaweicloud-sdk-go-obs/obs" + qiniuauth "github.com/qiniu/go-sdk/v7/auth/qbox" + qiniustorage "github.com/qiniu/go-sdk/v7/storage" + qiniucredentials "github.com/qiniu/go-sdk/v7/storagev2/credentials" + qiniuhttpclient "github.com/qiniu/go-sdk/v7/storagev2/http_client" + qiniuuploader "github.com/qiniu/go-sdk/v7/storagev2/uploader" + tencentcos "github.com/tencentyun/cos-go-sdk-v5" +) + +var ( + storageBucketPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{1,126}[A-Za-z0-9]$`) + storagePrefixPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9/_-]{0,119}$`) +) + +type mediaObjectStorage interface { + Put(context.Context, string, string, int64, io.Reader) error + Delete(context.Context, string) error + Bucket() string +} + +func storageProviderName(provider string) string { + switch provider { + case "local": + return "本地存储" + case "aliyun_oss": + return "阿里云 OSS" + case "tencent_cos": + return "腾讯云 COS" + case "qiniu": + return "七牛云存储" + case "huawei_obs": + return "华为云 OBS" + case "huawei_flexus": + return "华为云 Flexus 对象存储" + default: + return provider + } +} + +func parseStorageHTTPSURL(raw string, allowPath bool) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf("必须是无账号、查询参数和片段的有效 HTTPS 地址") + } + if !allowPath && strings.Trim(parsed.EscapedPath(), "/") != "" { + return nil, fmt.Errorf("地址不能包含路径") + } + return parsed, nil +} + +func validateLocalStorageDirectory(raw string) error { + directory := strings.TrimSpace(raw) + if directory == "" { + return fmt.Errorf("请填写本地存储目录") + } + abs, err := filepath.Abs(filepath.Clean(directory)) + if err != nil { + return fmt.Errorf("本地存储目录无效") + } + root := filepath.VolumeName(abs) + string(os.PathSeparator) + if strings.EqualFold(filepath.Clean(abs), filepath.Clean(root)) { + return fmt.Errorf("本地存储目录不能是磁盘根目录") + } + if info, statErr := os.Stat(abs); statErr == nil && !info.IsDir() { + return fmt.Errorf("本地存储目录指向了文件") + } else if statErr != nil && !os.IsNotExist(statErr) { + return fmt.Errorf("无法访问本地存储目录") + } + return nil +} + +func (a *App) validateStorageProviderConfig(ctx context.Context) error { + provider := a.configPlain(ctx, "storage.provider", "local") + providers := []string{"local", "aliyun_oss", "tencent_cos", "qiniu", "huawei_obs", "huawei_flexus"} + if !containsString(providers, provider) { + return fmt.Errorf("不支持的文件存储厂商 %q", provider) + } + for _, spec := range integrationSpecs["storage"] { + if !spec.Required || (len(spec.Providers) > 0 && !containsString(spec.Providers, provider)) { + continue + } + if strings.TrimSpace(a.configPlain(ctx, spec.Key, "")) == "" { + return fmt.Errorf("请填写%s", spec.Label) + } + } + prefix := strings.Trim(strings.TrimSpace(a.configPlain(ctx, "storage.object_prefix", "media")), "/") + if !storagePrefixPattern.MatchString(prefix) || strings.Contains(prefix, "//") || strings.Contains(prefix, "..") { + return fmt.Errorf("云端对象前缀格式无效") + } + + if provider == "local" { + if err := validateLocalStorageDirectory(a.configPlain(ctx, "storage.local.directory", a.config.MediaDir)); err != nil { + return err + } + baseURL := strings.TrimSpace(a.configPlain(ctx, "storage.local.public_base_url", "")) + if baseURL != "" { + parsed, err := url.Parse(baseURL) + if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return fmt.Errorf("本地公开访问地址无效") + } + if a.config.Environment == "production" && parsed.Scheme != "https" { + return fmt.Errorf("生产环境本地公开访问地址必须使用 HTTPS") + } + } + return nil + } + + keyPrefix := "storage." + provider + "." + if provider == "qiniu" { + if !storageBucketPattern.MatchString(a.configPlain(ctx, keyPrefix+"bucket", "")) { + return fmt.Errorf("七牛云空间名称格式无效") + } + } else { + if _, err := parseStorageHTTPSURL(a.configPlain(ctx, keyPrefix+"endpoint", ""), false); err != nil { + return fmt.Errorf("%s Endpoint %v", storageProviderName(provider), err) + } + if !storageBucketPattern.MatchString(a.configPlain(ctx, keyPrefix+"bucket", "")) { + return fmt.Errorf("%s Bucket 名称格式无效", storageProviderName(provider)) + } + } + if _, err := parseStorageHTTPSURL(a.configPlain(ctx, keyPrefix+"public_base_url", ""), true); err != nil { + return fmt.Errorf("%s文件访问域名%v", storageProviderName(provider), err) + } + return nil +} + +func storagePublicURL(baseURL, objectKey string) string { + return strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/" + strings.TrimLeft(objectKey, "/") +} + +func storageHTTPClient() *http.Client { + return &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +type aliyunOSSStorage struct { + bucket string + client *aliyunoss.Client +} + +func (s *aliyunOSSStorage) Bucket() string { return s.bucket } +func (s *aliyunOSSStorage) Put(ctx context.Context, key, contentType string, size int64, body io.Reader) error { + _, err := s.client.PutObject(ctx, &aliyunoss.PutObjectRequest{ + Bucket: aliyunoss.Ptr(s.bucket), Key: aliyunoss.Ptr(key), Body: body, + ContentType: aliyunoss.Ptr(contentType), ContentLength: aliyunoss.Ptr(size), + }) + return err +} +func (s *aliyunOSSStorage) Delete(ctx context.Context, key string) error { + _, err := s.client.DeleteObject(ctx, &aliyunoss.DeleteObjectRequest{Bucket: aliyunoss.Ptr(s.bucket), Key: aliyunoss.Ptr(key)}) + return err +} + +type tencentCOSStorage struct { + bucket string + client *tencentcos.Client +} + +func (s *tencentCOSStorage) Bucket() string { return s.bucket } +func (s *tencentCOSStorage) Put(ctx context.Context, key, contentType string, size int64, body io.Reader) error { + _, err := s.client.Object.Put(ctx, key, body, &tencentcos.ObjectPutOptions{ObjectPutHeaderOptions: &tencentcos.ObjectPutHeaderOptions{ContentType: contentType, ContentLength: size}}) + return err +} +func (s *tencentCOSStorage) Delete(ctx context.Context, key string) error { + _, err := s.client.Object.Delete(ctx, key) + return err +} + +type qiniuStorage struct { + bucket string + uploader *qiniuuploader.UploadManager + deleteMac *qiniuauth.Mac +} + +func (s *qiniuStorage) Bucket() string { return s.bucket } +func (s *qiniuStorage) Put(ctx context.Context, key, contentType string, _ int64, body io.Reader) error { + return s.uploader.UploadReader(ctx, body, &qiniuuploader.ObjectOptions{BucketName: s.bucket, ObjectName: &key, FileName: filepath.Base(key), ContentType: contentType}, nil) +} +func (s *qiniuStorage) Delete(_ context.Context, key string) error { + manager := qiniustorage.NewBucketManager(s.deleteMac, &qiniustorage.Config{UseHTTPS: true}) + return manager.Delete(s.bucket, key) +} + +type huaweiOBSStorage struct { + bucket string + client *huaweiobs.ObsClient +} + +func (s *huaweiOBSStorage) Bucket() string { return s.bucket } +func (s *huaweiOBSStorage) Put(_ context.Context, key, contentType string, size int64, body io.Reader) error { + _, err := s.client.PutObject(&huaweiobs.PutObjectInput{PutObjectBasicInput: huaweiobs.PutObjectBasicInput{ + ObjectOperationInput: huaweiobs.ObjectOperationInput{Bucket: s.bucket, Key: key}, + HttpHeader: huaweiobs.HttpHeader{ContentType: contentType}, ContentLength: size, + }, Body: body}) + return err +} +func (s *huaweiOBSStorage) Delete(_ context.Context, key string) error { + _, err := s.client.DeleteObject(&huaweiobs.DeleteObjectInput{Bucket: s.bucket, Key: key}) + return err +} + +func (a *App) newMediaObjectStorage(ctx context.Context) (mediaObjectStorage, func(), error) { + if err := a.validateStorageProviderConfig(ctx); err != nil { + return nil, func() {}, err + } + provider := a.configPlain(ctx, "storage.provider", "local") + keyPrefix := "storage." + provider + "." + switch provider { + case "aliyun_oss": + config := aliyunoss.LoadDefaultConfig(). + WithCredentialsProvider(aliyuncredentials.NewStaticCredentialsProvider(a.configPlain(ctx, keyPrefix+"access_key_id", ""), a.configPlain(ctx, keyPrefix+"access_key_secret", ""))). + WithRegion(a.configPlain(ctx, keyPrefix+"region", "")). + WithEndpoint(a.configPlain(ctx, keyPrefix+"endpoint", "")). + WithConnectTimeout(5 * time.Second). + WithReadWriteTimeout(30 * time.Second). + WithRetryMaxAttempts(3) + return &aliyunOSSStorage{bucket: a.configPlain(ctx, keyPrefix+"bucket", ""), client: aliyunoss.NewClient(config)}, func() {}, nil + case "tencent_cos": + endpoint, _ := url.Parse(a.configPlain(ctx, keyPrefix+"endpoint", "")) + client := tencentcos.NewClient(&tencentcos.BaseURL{BucketURL: endpoint}, &http.Client{ + Timeout: 30 * time.Second, + Transport: &tencentcos.AuthorizationTransport{SecretID: a.configPlain(ctx, keyPrefix+"secret_id", ""), SecretKey: a.configPlain(ctx, keyPrefix+"secret_key", "")}, + }) + return &tencentCOSStorage{bucket: a.configPlain(ctx, keyPrefix+"bucket", ""), client: client}, func() {}, nil + case "qiniu": + accessKey, secretKey := a.configPlain(ctx, keyPrefix+"access_key", ""), a.configPlain(ctx, keyPrefix+"secret_key", "") + manager := qiniuuploader.NewUploadManager(&qiniuuploader.UploadManagerOptions{Options: qiniuhttpclient.Options{Credentials: qiniucredentials.NewCredentials(accessKey, secretKey), BasicHTTPClient: storageHTTPClient()}, MultiPartsThreshold: 8 << 20, PartSize: 4 << 20, Concurrency: 2}) + return &qiniuStorage{bucket: a.configPlain(ctx, keyPrefix+"bucket", ""), uploader: manager, deleteMac: qiniuauth.NewMac(accessKey, secretKey)}, func() {}, nil + case "huawei_obs", "huawei_flexus": + client, err := huaweiobs.New(a.configPlain(ctx, keyPrefix+"access_key", ""), a.configPlain(ctx, keyPrefix+"secret_key", ""), a.configPlain(ctx, keyPrefix+"endpoint", ""), huaweiobs.WithConnectTimeout(5), huaweiobs.WithSocketTimeout(30), huaweiobs.WithMaxRetryCount(2)) + if err != nil { + return nil, func() {}, err + } + return &huaweiOBSStorage{bucket: a.configPlain(ctx, keyPrefix+"bucket", ""), client: client}, client.Close, nil + default: + return nil, func() {}, nil + } +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/storage_providers_test.go b/im/.deploy/application-20260831-120612/backend/internal/app/storage_providers_test.go new file mode 100644 index 0000000..4bbd4ad --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/storage_providers_test.go @@ -0,0 +1,44 @@ +package app + +import ( + "path/filepath" + "testing" +) + +func TestParseStorageHTTPSURL(t *testing.T) { + tests := []struct { + url string + allowPath bool + valid bool + }{ + {url: "https://oss-cn-hangzhou.aliyuncs.com", valid: true}, + {url: "https://cdn.example.com/media", allowPath: true, valid: true}, + {url: "http://oss.example.com", valid: false}, + {url: "https://user:pass@oss.example.com", valid: false}, + {url: "https://oss.example.com/path", valid: false}, + {url: "https://oss.example.com?token=secret", valid: false}, + } + for _, test := range tests { + _, err := parseStorageHTTPSURL(test.url, test.allowPath) + if (err == nil) != test.valid { + t.Errorf("parseStorageHTTPSURL(%q, %v) error = %v, valid = %v", test.url, test.allowPath, err, test.valid) + } + } +} + +func TestValidateLocalStorageDirectoryRejectsRoot(t *testing.T) { + root := filepath.VolumeName(t.TempDir()) + string(filepath.Separator) + if err := validateLocalStorageDirectory(root); err == nil { + t.Fatalf("expected volume root %q to be rejected", root) + } + if err := validateLocalStorageDirectory(filepath.Join(t.TempDir(), "uploads")); err != nil { + t.Fatalf("expected nested upload directory to be accepted: %v", err) + } +} + +func TestStoragePublicURL(t *testing.T) { + got := storagePublicURL("https://cdn.example.com/media/", "/2026/08/test.png") + if got != "https://cdn.example.com/media/2026/08/test.png" { + t.Fatalf("unexpected public URL: %s", got) + } +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/test_users.go b/im/.deploy/application-20260831-120612/backend/internal/app/test_users.go new file mode 100644 index 0000000..2617594 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/test_users.go @@ -0,0 +1,29 @@ +package app + +import ( + "fmt" + "strings" +) + +func testUserFilter(userType, batch string) (string, []any, error) { + where := "" + args := []any{} + switch userType { + case "": + case "test": + where = " AND u.is_test=1" + case "registered": + where = " AND u.is_test=0" + default: + return "", nil, fmt.Errorf("用户类型筛选无效") + } + batch = strings.TrimSpace(batch) + if len(batch) > 64 { + return "", nil, fmt.Errorf("测试批次长度不能超过64位") + } + if batch != "" { + where += " AND u.is_test=1 AND u.test_batch=?" + args = append(args, batch) + } + return where, args, nil +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/test_users_test.go b/im/.deploy/application-20260831-120612/backend/internal/app/test_users_test.go new file mode 100644 index 0000000..c5addd7 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/test_users_test.go @@ -0,0 +1,41 @@ +package app + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestUserTypeFilters(t *testing.T) { + for _, item := range []struct{ kind, expected string }{{"", ""}, {"test", " AND u.is_test=1"}, {"registered", " AND u.is_test=0"}} { + where, args, err := testUserFilter(item.kind, "") + if err != nil || where != item.expected || len(args) != 0 { + t.Fatalf("filter %q: %q %v %v", item.kind, where, args, err) + } + } + if _, _, err := testUserFilter("invalid", ""); err == nil { + t.Fatal("invalid type accepted") + } + if _, _, err := testUserFilter("", strings.Repeat("x", 65)); err == nil { + t.Fatal("oversized batch accepted") + } + batch := "' OR 1=1 --" + where, args, err := testUserFilter("test", batch) + if err != nil || strings.Contains(where, batch) || !strings.Contains(where, "u.test_batch=?") || len(args) != 1 || args[0] != batch { + t.Fatal("batch must be an exact bound SQL value") + } +} + +func TestProfileExposesExplicitTestFlag(t *testing.T) { + for _, isTest := range []bool{false, true} { + data, err := json.Marshal(profileView{IsTest: isTest}) + if err != nil { + t.Fatal(err) + } + var result map[string]any + _ = json.Unmarshal(data, &result) + if result["isTest"] != isTest { + t.Fatalf("test label lost in profile JSON: %s", data) + } + } +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/app/user_oauth.go b/im/.deploy/application-20260831-120612/backend/internal/app/user_oauth.go new file mode 100644 index 0000000..232e22d --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/app/user_oauth.go @@ -0,0 +1,401 @@ +package app + +import ( + "context" + "database/sql" + "errors" + "net/http" + "net/url" + "strings" + "time" +) + +type userOAuthLoginCode struct { + Provider string + Platform string + IdentityScope string + AppProofHash []byte + Subject string + Email string + DisplayName string + AvatarURL string + UserID sql.NullInt64 +} + +func (a *App) userOAuthFrontendURL(ctx context.Context) (string, error) { + raw := strings.TrimSpace(a.configPlain(ctx, "oauth.user.frontend_callback_url", "")) + if raw == "" { + return "", errors.New("客户端第三方登录结果页未配置") + } + if err := a.validateAdminOAuthRedirectURL(raw); err != nil { + return "", err + } + return raw, nil +} + +func (a *App) enabledUserOAuthProviders(ctx context.Context) ([]adminOAuthProvider, error) { + providers := make([]adminOAuthProvider, 0, len(adminOAuthProviderNames)) + for _, code := range []string{"wechat", "qq", "github", "google"} { + if !a.configBool(ctx, "oauth.user."+code+".enabled", false) { + continue + } + provider, err := a.adminOAuthProvider(ctx, code) + if err != nil { + return nil, err + } + providers = append(providers, provider) + } + return providers, nil +} + +// oauthCallback lets one provider callback URL safely serve both the admin +// console and the uni-app H5 client. The random state value selects the +// audience; it is never accepted by both state tables. +func (a *App) oauthCallback(w http.ResponseWriter, r *http.Request) { + state := strings.TrimSpace(r.URL.Query().Get("state")) + if state != "" { + var exists int + if a.db.QueryRowContext(r.Context(), `SELECT 1 FROM user_oauth_states WHERE state_hash=?`, oauthHash(state)).Scan(&exists) == nil { + a.userOAuthCallback(w, r) + return + } + if a.db.QueryRowContext(r.Context(), `SELECT 1 FROM admin_oauth_states WHERE state_hash=?`, oauthHash(state)).Scan(&exists) == nil { + a.adminOAuthCallback(w, r) + return + } + } + if strings.HasPrefix(r.URL.Path, "/api/") { + a.userOAuthCallback(w, r) + return + } + a.adminOAuthCallback(w, r) +} + +func (a *App) userOAuthProviders(w http.ResponseWriter, r *http.Request) { + platform, err := oauthClientPlatform(r.URL.Query().Get("platform")) + if err != nil { + fail(w, 400, 20001, err.Error()) + return + } + items := make([]map[string]string, 0, len(adminOAuthProviderNames)) + for _, code := range []string{"wechat", "qq", "github", "google"} { + if !a.configBool(r.Context(), userOAuthEnabledKey(platform, code), false) { + continue + } + var provider adminOAuthProvider + var err error + if platform == "app" { + provider, err = a.appOAuthProvider(r.Context(), code) + } else { + provider, err = a.adminOAuthProvider(r.Context(), code) + } + if err != nil { + continue + } + items = append(items, map[string]string{"code": provider.Code, "name": provider.Name}) + } + w.Header().Set("Cache-Control", "no-store") + reply(w, map[string]any{"items": items}) +} + +func (a *App) userOAuthStart(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + Platform string `json:"platform"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "请选择第三方登录渠道") + return + } + req.Provider = strings.ToLower(strings.TrimSpace(req.Provider)) + platform, err := oauthClientPlatform(req.Platform) + if err != nil { + fail(w, 400, 20001, err.Error()) + return + } + if platform == "app" && req.Provider != "github" { + fail(w, 400, 20001, "该 App 渠道需使用原生 SDK 授权") + return + } + if !a.rateLimit(w, r, "user_oauth_start", clientIP(r), 30, 10*time.Minute) { + return + } + if !a.configBool(r.Context(), userOAuthEnabledKey(platform, req.Provider), false) { + fail(w, http.StatusBadRequest, 20001, "该客户端登录方式未启用") + return + } + provider, err := a.adminOAuthProvider(r.Context(), req.Provider) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "该登录方式配置不完整") + return + } + if platform == "app" { + _, err = a.appOAuthFrontendURL(r.Context()) + } else { + _, err = a.userOAuthFrontendURL(r.Context()) + } + if err != nil { + fail(w, http.StatusBadRequest, 20001, "客户端登录结果页配置不完整") + return + } + state := randomToken() + verifier := "" + if provider.Code == "github" || provider.Code == "google" { + verifier = randomToken() + randomToken() + } + appProof := "" + var proofHash []byte + if platform == "app" { + appProof = randomToken() + proofHash = oauthHash(appProof) + } + _, err = a.db.ExecContext(r.Context(), `INSERT INTO user_oauth_states(state_hash,provider,code_verifier,expires_at,client_platform,app_proof_hash) VALUES(?,?,?,?,?,?)`, oauthHash(state), provider.Code, verifier, time.Now().Add(adminOAuthStateTTL), platform, proofHash) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建第三方登录请求失败") + return + } + a.cleanupUserOAuthRecords(r.Context()) + + authorizationURL, _ := url.Parse(provider.AuthorizationURL) + query := authorizationURL.Query() + if provider.Code == "wechat" { + query.Set("appid", provider.ClientID) + } else { + query.Set("client_id", provider.ClientID) + } + query.Set("redirect_uri", provider.RedirectURI) + query.Set("response_type", "code") + query.Set("scope", provider.Scope) + query.Set("state", state) + if verifier != "" { + query.Set("code_challenge", pkceChallenge(verifier)) + query.Set("code_challenge_method", "S256") + } + authorizationURL.RawQuery = query.Encode() + if provider.Code == "wechat" { + authorizationURL.Fragment = "wechat_redirect" + } + w.Header().Set("Cache-Control", "no-store") + response := map[string]string{"authorizationUrl": authorizationURL.String(), "provider": provider.Code} + if platform == "app" { + response["appProof"] = appProof + response["requestId"] = state + response["callbackUrl"] = appOAuthCallbackURL + } + reply(w, response) +} + +func (a *App) userOAuthCallback(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + state := strings.TrimSpace(r.URL.Query().Get("state")) + var providerCode, verifier, platform string + var proofHash []byte + err := a.db.QueryRowContext(r.Context(), `SELECT provider,code_verifier,client_platform,app_proof_hash FROM user_oauth_states WHERE state_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(state)).Scan(&providerCode, &verifier, &platform, &proofHash) + if err != nil { + fail(w, 400, 20001, "登录状态无效或已过期,请返回客户端重试") + return + } + var frontendURL string + if platform == "app" { + frontendURL, err = a.appOAuthFrontendURL(r.Context()) + if err == nil { + frontendURL += "?requestId=" + url.QueryEscape(state) + } + } else { + frontendURL, err = a.userOAuthFrontendURL(r.Context()) + } + if err != nil { + fail(w, 503, 50001, "客户端第三方登录回调未配置") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE user_oauth_states SET used_at=NOW(3) WHERE state_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(state)) + if err != nil { + a.redirectUserOAuthResult(w, r, frontendURL, "", "第三方登录处理失败") + return + } + affected, _ := result.RowsAffected() + if affected != 1 { + a.redirectUserOAuthResult(w, r, frontendURL, "", "登录状态已被使用") + return + } + if strings.TrimSpace(r.URL.Query().Get("error")) != "" { + a.redirectUserOAuthResult(w, r, frontendURL, "", "第三方授权已取消或失败") + return + } + code := strings.TrimSpace(r.URL.Query().Get("code")) + if code == "" { + a.redirectUserOAuthResult(w, r, frontendURL, "", "第三方平台未返回授权码") + return + } + if !a.configBool(r.Context(), userOAuthEnabledKey(platform, providerCode), false) { + a.redirectUserOAuthResult(w, r, frontendURL, "", "该客户端登录方式已停用") + return + } + provider, err := a.adminOAuthProvider(r.Context(), providerCode) + if err != nil { + a.redirectUserOAuthResult(w, r, frontendURL, "", "该登录方式配置不可用") + return + } + identity, err := a.fetchAdminOAuthIdentity(r.Context(), provider, code, verifier) + if err != nil { + a.redirectUserOAuthResult(w, r, frontendURL, "", "获取第三方账号信息失败") + return + } + loginCode, err := a.issueUserOAuthCode(r.Context(), provider.Code, platform, "", proofHash, identity) + if err != nil { + a.redirectUserOAuthResult(w, r, frontendURL, "", "创建登录凭证失败") + return + } + a.redirectUserOAuthResult(w, r, frontendURL, loginCode, "") +} + +func (a *App) redirectUserOAuthResult(w http.ResponseWriter, r *http.Request, frontendURL, code, message string) { + target, err := url.Parse(frontendURL) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "客户端登录结果页地址无效") + return + } + query := target.Query() + if code != "" { + query.Set("oauthCode", code) + } else { + query.Set("oauthError", message) + } + target.RawQuery = query.Encode() + http.Redirect(w, r, target.String(), http.StatusFound) +} + +func (a *App) userOAuthExchange(w http.ResponseWriter, r *http.Request) { + var req struct { + Code string `json:"code"` + DeviceID string `json:"deviceId"` + AppProof string `json:"appProof"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效") + return + } + if !a.rateLimit(w, r, "user_oauth_exchange", clientIP(r), 20, 10*time.Minute) { + return + } + loginCode, err := a.readUserOAuthLoginCode(r.Context(), strings.TrimSpace(req.Code)) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效或已过期") + return + } + if err = a.validateUserOAuthCode(r.Context(), loginCode, req.AppProof); err != nil { + fail(w, 400, 20001, err.Error()) + return + } + if !loginCode.UserID.Valid { + reply(w, map[string]any{ + "requiresLink": true, + "provider": loginCode.Provider, + "providerName": adminOAuthProviderNames[loginCode.Provider], + "displayName": loginCode.DisplayName, + "avatarUrl": loginCode.AvatarURL, + }) + return + } + userID, nickname, err := a.consumeUserOAuthCode(r.Context(), strings.TrimSpace(req.Code), loginCode.UserID.Int64) + if err != nil { + fail(w, http.StatusUnauthorized, 10001, err.Error()) + return + } + a.finishLogin(w, r, userID, nickname, req.DeviceID) +} + +func (a *App) userOAuthLink(w http.ResponseWriter, r *http.Request) { + var req struct { + Code string `json:"code"` + Phone string `json:"phone"` + SMSCode string `json:"smsCode"` + DeviceID string `json:"deviceId"` + AppProof string `json:"appProof"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" || !validPhone(req.Phone) || len(req.SMSCode) != 6 { + fail(w, http.StatusBadRequest, 20001, "请输入已注册手机号和正确的短信验证码") + return + } + phone := strings.TrimSpace(req.Phone) + if !a.rateLimit(w, r, "user_oauth_link_ip", clientIP(r), 10, 15*time.Minute) || !a.rateLimit(w, r, "user_oauth_link_phone", phone, 10, 15*time.Minute) { + return + } + loginCode, err := a.readUserOAuthLoginCode(r.Context(), strings.TrimSpace(req.Code)) + if err != nil || loginCode.UserID.Valid { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效、已绑定或已过期") + return + } + if err = a.validateUserOAuthCode(r.Context(), loginCode, req.AppProof); err != nil { + fail(w, 400, 20001, err.Error()) + return + } + var userID int64 + var nickname string + var status int + err = a.db.QueryRowContext(r.Context(), `SELECT u.id,p.nickname,u.status FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.phone_hash=? AND u.deleted_at IS NULL`, phoneHash(phone)).Scan(&userID, &nickname, &status) + if err != nil || status != 1 { + fail(w, http.StatusBadRequest, 20001, "手机号未注册或账号当前不可用") + return + } + if !a.consumeSMSCode(r, phone, "login", req.SMSCode) { + fail(w, http.StatusBadRequest, 20001, "验证码错误或已过期") + return + } + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(r.Context(), `UPDATE user_oauth_login_codes SET used_at=NOW(3),user_id=? WHERE code_hash=? AND user_id IS NULL AND used_at IS NULL AND expires_at>NOW(3)`, userID, oauthHash(strings.TrimSpace(req.Code))) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + affected, _ := result.RowsAffected() + if affected != 1 { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证已被使用") + return + } + _, err = tx.ExecContext(r.Context(), `INSERT INTO user_oauth_identities(provider,subject,user_id,email,display_name,avatar_url,identity_scope,last_login_at) VALUES(?,?,?,?,?,?,?,NOW(3))`, loginCode.Provider, loginCode.Subject, userID, loginCode.Email, loginCode.DisplayName, loginCode.AvatarURL, loginCode.IdentityScope) + if err != nil { + fail(w, http.StatusConflict, 20001, "该第三方账号或手机号已绑定此渠道") + return + } + if err = tx.Commit(); err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + a.finishLogin(w, r, userID, nickname, req.DeviceID) +} + +func (a *App) readUserOAuthLoginCode(ctx context.Context, code string) (userOAuthLoginCode, error) { + var result userOAuthLoginCode + err := a.db.QueryRowContext(ctx, `SELECT provider,subject,email,display_name,avatar_url,user_id,client_platform,identity_scope,app_proof_hash FROM user_oauth_login_codes WHERE code_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(code)).Scan(&result.Provider, &result.Subject, &result.Email, &result.DisplayName, &result.AvatarURL, &result.UserID, &result.Platform, &result.IdentityScope, &result.AppProofHash) + return result, err +} + +func (a *App) consumeUserOAuthCode(ctx context.Context, code string, userID int64) (int64, string, error) { + var nickname string + var status int + if err := a.db.QueryRowContext(ctx, `SELECT p.nickname,u.status FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.id=? AND u.deleted_at IS NULL`, userID).Scan(&nickname, &status); err != nil || status != 1 { + return 0, "", errors.New("账号不存在或当前不可用") + } + result, err := a.db.ExecContext(ctx, `UPDATE user_oauth_login_codes SET used_at=NOW(3) WHERE code_hash=? AND user_id=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(code), userID) + if err != nil { + return 0, "", errors.New("第三方登录处理失败") + } + affected, _ := result.RowsAffected() + if affected != 1 { + return 0, "", errors.New("第三方登录凭证无效或已使用") + } + _, _ = a.db.ExecContext(ctx, `UPDATE user_oauth_identities SET last_login_at=NOW(3) WHERE user_id=?`, userID) + return userID, nickname, nil +} + +func (a *App) cleanupUserOAuthRecords(ctx context.Context) { + _, _ = a.db.ExecContext(ctx, `DELETE FROM user_oauth_states WHERE expires_at 50 || len([]rune(documentNumber)) < 6 || len([]rune(documentNumber)) > 80 { + fail(w, http.StatusBadRequest, 20001, "姓名或证件号码格式无效") + return + } + if len(req.Evidence) == 0 || len(req.Evidence) > 3 { + fail(w, http.StatusBadRequest, 20001, "请上传 1 至 3 张认证材料") + return + } + + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "提交认证失败") + return + } + defer func() { _ = tx.Rollback() }() + var existingStatus string + err = tx.QueryRowContext(r.Context(), `SELECT status FROM user_verifications WHERE user_id=? AND verification_type=? FOR UPDATE`, current(r).ID, req.Type).Scan(&existingStatus) + if err != nil && err != sql.ErrNoRows { + fail(w, http.StatusInternalServerError, 50001, "读取认证状态失败") + return + } + if existingStatus == "PENDING" { + fail(w, http.StatusConflict, 20001, "认证资料正在审核,请勿重复提交") + return + } + if existingStatus == "VERIFIED" { + fail(w, http.StatusConflict, 20001, "账号已完成认证") + return + } + + evidence := make([]string, 0, len(req.Evidence)) + seen := map[string]bool{} + for _, rawURL := range req.Evidence { + mediaURL := strings.TrimSpace(rawURL) + if mediaURL == "" || seen[mediaURL] { + fail(w, http.StatusBadRequest, 20001, "认证材料地址无效或重复") + return + } + seen[mediaURL] = true + var exists int + if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM media_assets WHERE owner_user_id=? AND public_url=? AND media_type='image' AND status=1)`, current(r).ID, mediaURL).Scan(&exists); err != nil || exists != 1 { + fail(w, http.StatusBadRequest, 20001, "认证材料必须由当前账号上传") + return + } + evidence = append(evidence, mediaURL) + } + evidenceJSON, _ := json.Marshal(evidence) + _, err = tx.ExecContext(r.Context(), `INSERT INTO user_verifications(user_id,verification_type,status,real_name,document_mask,document_hash,evidence_json,remark,reviewer_admin_id,submitted_at,reviewed_at) + VALUES(?,?,'PENDING',?,?,?,?, '',NULL,NOW(3),NULL) + ON DUPLICATE KEY UPDATE verification_type=VALUES(verification_type),status='PENDING',real_name=VALUES(real_name),document_mask=VALUES(document_mask),document_hash=VALUES(document_hash),evidence_json=VALUES(evidence_json),remark='',reviewer_admin_id=NULL,submitted_at=NOW(3),reviewed_at=NULL`, current(r).ID, req.Type, req.RealName, maskDocumentNumber(documentNumber), a.verificationDocumentHash(documentNumber), string(evidenceJSON)) + if err != nil || tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "提交认证失败") + return + } + reply(w, map[string]any{"status": "PENDING", "submitted": true}) +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/testusers/assets.go b/im/.deploy/application-20260831-120612/backend/internal/testusers/assets.go new file mode 100644 index 0000000..2ecff98 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/testusers/assets.go @@ -0,0 +1,57 @@ +package testusers + +import ( + "bytes" + "fmt" + "image/png" + "os" + "path/filepath" +) + +// CopyAvatars validates all sources and destination collisions before copying. +// Existing identical assets are reused. Existing different files are not replaced. +func CopyAvatars(sourceDir, mediaDir string) error { + if sourceDir == "" || mediaDir == "" { + return fmt.Errorf("both avatar source and media directories are required") + } + assets := make(map[string][]byte) + for _, name := range Avatars() { + data, err := os.ReadFile(filepath.Join(sourceDir, name)) + if err != nil { + return err + } + cfg, err := png.DecodeConfig(bytes.NewReader(data)) + if err != nil || cfg.Width < 256 || cfg.Height < 256 { + return fmt.Errorf("invalid or undersized PNG avatar: %s", name) + } + existing, err := os.ReadFile(filepath.Join(mediaDir, name)) + if err == nil { + if !bytes.Equal(existing, data) { + return fmt.Errorf("refusing to overwrite different existing avatar: %s", name) + } + continue + } + if !os.IsNotExist(err) { + return err + } + assets[name] = data + } + if err := os.MkdirAll(mediaDir, 0755); err != nil { + return err + } + for name, data := range assets { + file, err := os.OpenFile(filepath.Join(mediaDir, name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) + if err != nil { + return err + } + _, writeErr := file.Write(data) + closeErr := file.Close() + if writeErr != nil { + return writeErr + } + if closeErr != nil { + return closeErr + } + } + return nil +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/testusers/fixtures.go b/im/.deploy/application-20260831-120612/backend/internal/testusers/fixtures.go new file mode 100644 index 0000000..acd4b74 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/testusers/fixtures.go @@ -0,0 +1,75 @@ +// Package testusers creates explicitly labelled, non-loginable test profiles. +// It is only used by an operator-invoked CLI, never during server startup. +package testusers + +import ( + "fmt" + "net/url" + "strings" +) + +const Batch = "cn-adults-20260831-v1" +const Count = 100 + +type Profile struct { + PublicID string `json:"publicId"` + IsTest bool `json:"isTest"` + TestBatch string `json:"testBatch"` + Label string `json:"label"` + Nickname string `json:"nickname"` + Gender int `json:"gender"` + Birthday string `json:"birthday"` + Height int `json:"height"` + CityCode string `json:"cityCode"` + City string `json:"city"` + Occupation string `json:"occupation"` + Bio string `json:"bio"` + AvatarFile string `json:"avatarFile"` + Avatar string `json:"avatar"` +} + +// Avatars contains five independently generated adult portraits per gender. +// Reuse is intentional for fixtures; these are not 100 real identities. +func Avatars() []string { + files := make([]string, 0, 10) + for gender := 1; gender <= 2; gender++ { + for n := 1; n <= 5; n++ { + files = append(files, fmt.Sprintf("900028%d%d-1.png", gender, n)) + } + } + return files +} + +func Generate(publicBase string) ([]Profile, error) { + base, err := url.Parse(strings.TrimRight(publicBase, "/")) + if err != nil || base.Host == "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" || (base.Scheme != "http" && base.Scheme != "https") { + return nil, fmt.Errorf("public base must be an absolute HTTP(S) uploads URL without credentials, query or fragment") + } + if base.Scheme == "http" && base.Hostname() != "127.0.0.1" && base.Hostname() != "localhost" && base.Hostname() != "::1" { + return nil, fmt.Errorf("non-local avatar URLs require HTTPS") + } + surnames := []string{"陈", "林", "周", "许", "苏", "沈", "陆", "顾", "方", "季"} + given := [][]string{{"沐川", "知远", "星河", "景行", "予安"}, {"晚晴", "知夏", "语桐", "清禾", "若宁"}} + cities := [][2]string{{"310100", "上海"}, {"440100", "广州"}, {"440300", "深圳"}, {"330100", "杭州"}, {"510100", "成都"}, {"420100", "武汉"}, {"320100", "南京"}, {"350200", "厦门"}, {"610100", "西安"}, {"370200", "青岛"}} + jobs := []string{"设计师", "工程师", "教师", "摄影师", "产品经理"} + hobbies := []string{"摄影与城市漫步", "阅读与咖啡", "跑步与音乐", "旅行与美食", "电影与绘画"} + ages := [2][5]int{{27, 30, 24, 33, 28}, {26, 29, 24, 32, 27}} + items := make([]Profile, 0, Count) + for gender := 1; gender <= 2; gender++ { + for i := 0; i < 50; i++ { + avatarFile := fmt.Sprintf("900028%d%d-1.png", gender, i%5+1) + city := cities[i%len(cities)] + items = append(items, Profile{ + PublicID: fmt.Sprintf("TESTCN%06d", (gender-1)*50+i+1), + IsTest: true, TestBatch: Batch, Label: "测试用户", + Nickname: "测试·" + surnames[i/5] + given[gender-1][i%5], + Gender: gender, Birthday: fmt.Sprintf("%d-%02d-%02d", 2026-ages[gender-1][i%5], i%6+1, i%27+1), + Height: 160 + (2-gender)*12 + i%12, + CityCode: city[0], City: city[1], Occupation: jobs[i%len(jobs)], + Bio: "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:" + hobbies[i%len(hobbies)] + "。", + AvatarFile: avatarFile, Avatar: base.String() + "/" + avatarFile, + }) + } + } + return items, nil +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/testusers/fixtures_test.go b/im/.deploy/application-20260831-120612/backend/internal/testusers/fixtures_test.go new file mode 100644 index 0000000..568634a --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/testusers/fixtures_test.go @@ -0,0 +1,98 @@ +package testusers + +import ( + "bytes" + "image" + "image/png" + "os" + "path/filepath" + "reflect" + "regexp" + "strings" + "testing" + "time" +) + +func TestGenerateBalancedLabelledAdults(t *testing.T) { + items, err := Generate("https://im.bchongw.com/uploads/") + if err != nil || len(items) != 100 { + t.Fatalf("len=%d err=%v", len(items), err) + } + ids, names := map[string]bool{}, map[string]bool{} + counts := map[int]int{} + files := map[string]bool{} + for _, file := range Avatars() { + files[file] = true + } + for _, p := range items { + if ids[p.PublicID] || names[p.Nickname] || !p.IsTest || p.TestBatch != Batch || p.Label != "测试用户" || !strings.HasPrefix(p.Nickname, "测试·") { + t.Fatalf("missing label or duplicate identity: %+v", p) + } + ids[p.PublicID], names[p.Nickname] = true, true + counts[p.Gender]++ + birthday, err := time.Parse("2006-01-02", p.Birthday) + if err != nil || birthday.After(time.Date(2008, 8, 31, 0, 0, 0, 0, time.UTC)) { + t.Fatalf("invalid adult birthday: %s", p.Birthday) + } + if !files[p.AvatarFile] || p.Avatar != "https://im.bchongw.com/uploads/"+p.AvatarFile || !regexp.MustCompile(`^[0-9]+-[0-9]+\.png$`).MatchString(p.AvatarFile) { + t.Fatalf("avatar incompatible with media route: %s", p.Avatar) + } + if !strings.Contains(p.Bio, "非真实交友用户") { + t.Fatal("missing synthetic identity disclosure") + } + } + if counts[1] != 50 || counts[2] != 50 || len(counts) != 2 { + t.Fatalf("gender distribution: %v", counts) + } + again, _ := Generate("https://im.bchongw.com/uploads/") + if !reflect.DeepEqual(items, again) { + t.Fatal("fixture generation must be deterministic") + } +} + +func TestRejectUnsafePublicBase(t *testing.T) { + for _, base := range []string{"", "/uploads", "http://example.com/uploads", "https://user:secret@example.com/uploads", "https://example.com/uploads?x=1", "https://example.com/#bad", "javascript:alert(1)"} { + if _, err := Generate(base); err == nil { + t.Errorf("accepted unsafe base %q", base) + } + } + for _, base := range []string{"https://example.com/uploads", "http://127.0.0.1:8888/uploads", "http://localhost:8888/uploads"} { + if _, err := Generate(base); err != nil { + t.Errorf("rejected valid base %q: %v", base, err) + } + } +} + +func TestAvatarCopyIsRepeatableAndDoesNotOverwrite(t *testing.T) { + source, destination := t.TempDir(), t.TempDir() + var buf bytes.Buffer + if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 256, 256))); err != nil { + t.Fatal(err) + } + for _, file := range Avatars() { + if err := os.WriteFile(filepath.Join(source, file), buf.Bytes(), 0600); err != nil { + t.Fatal(err) + } + } + for i := 0; i < 2; i++ { + if err := CopyAvatars(source, destination); err != nil { + t.Fatal(err) + } + } + conflict := filepath.Join(destination, Avatars()[0]) + if err := os.WriteFile(conflict, []byte("existing user file"), 0600); err != nil { + t.Fatal(err) + } + if err := CopyAvatars(source, destination); err == nil { + t.Fatal("overwrote an unrelated file") + } + if got, _ := os.ReadFile(conflict); string(got) != "existing user file" { + t.Fatal("collision changed existing file") + } +} + +func TestBundledAvatarsArePresent(t *testing.T) { + if err := CopyAvatars(filepath.Join("..", "..", "..", "fixtures", "test-users", "avatars"), t.TempDir()); err != nil { + t.Fatal(err) + } +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/testusers/seed.go b/im/.deploy/application-20260831-120612/backend/internal/testusers/seed.go new file mode 100644 index 0000000..12e0616 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/testusers/seed.go @@ -0,0 +1,107 @@ +package testusers + +import ( + "context" + "database/sql" + "fmt" +) + +type Result struct { + Batch string `json:"batch"` + Created int `json:"created"` + Skipped int `json:"skipped"` + Male int `json:"male"` + Female int `json:"female"` + IDs []int64 `json:"ids"` +} + +// Seed is atomic and repeatable: rerunning a complete batch is a no-op. +// Conflicting real users and incomplete/modified batches are never overwritten. +func Seed(ctx context.Context, db *sql.DB, publicBase, confirmDatabase string) (Result, error) { + result := Result{Batch: Batch, IDs: []int64{}} + profiles, err := Generate(publicBase) + if err != nil { + return result, err + } + conn, err := db.Conn(ctx) + if err != nil { + return result, err + } + defer conn.Close() + var database string + if err = conn.QueryRowContext(ctx, `SELECT DATABASE()`).Scan(&database); err != nil { + return result, err + } + if confirmDatabase == "" || database != confirmDatabase { + return result, fmt.Errorf("database confirmation does not match the connected database") + } + var locked int + if err = conn.QueryRowContext(ctx, `SELECT GET_LOCK(?,10)`, "xingyu:testusers:"+Batch).Scan(&locked); err != nil || locked != 1 { + return result, fmt.Errorf("could not acquire test-user batch lock") + } + defer func() { _, _ = conn.ExecContext(context.Background(), `DO RELEASE_LOCK(?)`, "xingyu:testusers:"+Batch) }() + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + return result, err + } + defer tx.Rollback() + var batchCount int + if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE test_batch=?`, Batch).Scan(&batchCount); err != nil { + return result, fmt.Errorf("test-user schema unavailable; apply migration 028 first: %w", err) + } + if batchCount != 0 && batchCount != Count { + return result, fmt.Errorf("batch has %d users, expected 0 or %d; refusing to modify partial data", batchCount, Count) + } + for _, p := range profiles { + var id int64 + var isTest bool + var existingBatch string + var deleted sql.NullTime + err = tx.QueryRowContext(ctx, `SELECT id,is_test,test_batch,deleted_at FROM users WHERE public_id=? FOR UPDATE`, p.PublicID).Scan(&id, &isTest, &existingBatch, &deleted) + if err == nil { + if batchCount != Count || !isTest || existingBatch != Batch || deleted.Valid { + return Result{}, fmt.Errorf("public ID %s conflicts with an existing or deleted user; no users changed", p.PublicID) + } + var gender int + if err = tx.QueryRowContext(ctx, `SELECT gender FROM user_profiles WHERE user_id=?`, id).Scan(&gender); err != nil || gender != p.Gender { + return Result{}, fmt.Errorf("existing test profile %s has been modified or is incomplete", p.PublicID) + } + result.Skipped++ + } else if err == sql.ErrNoRows && batchCount == 0 { + res, insertErr := tx.ExecContext(ctx, `INSERT INTO users (public_id,password_hash,is_test,test_batch,status) VALUES (?,'!TEST_PROFILE_NO_LOGIN',1,?,1)`, p.PublicID, Batch) + if insertErr != nil { + return Result{}, insertErr + } + id, err = res.LastInsertId() + if err != nil { + return Result{}, err + } + _, err = tx.ExecContext(ctx, `INSERT INTO user_profiles (user_id,nickname,avatar_url,cover_url,gender,birthday,height_cm,city_code,city_name,occupation,bio,profile_score) VALUES (?,?,?,'',?,?,?,?,?,?,?,80)`, id, p.Nickname, p.Avatar, p.Gender, p.Birthday, p.Height, p.CityCode, p.City, p.Occupation, p.Bio) + if err != nil { + return Result{}, err + } + // Do not invent GPS, recent activity, identity verification or memberships. + // An empty phone and invalid password hash make these display-only fixtures. + _, err = tx.ExecContext(ctx, `INSERT INTO user_privacy_settings (user_id,distance_visible,online_visible,last_active_visible,allow_profile_visit_record) VALUES (?,0,0,0,0)`, id) + if err != nil { + return Result{}, err + } + result.Created++ + } else { + if err == sql.ErrNoRows { + err = fmt.Errorf("batch identity mismatch at %s", p.PublicID) + } + return Result{}, err + } + if p.Gender == 1 { + result.Male++ + } else { + result.Female++ + } + result.IDs = append(result.IDs, id) + } + if err = tx.Commit(); err != nil { + return Result{}, err + } + return result, nil +} diff --git a/im/.deploy/application-20260831-120612/backend/internal/testusers/seed_integration_test.go b/im/.deploy/application-20260831-120612/backend/internal/testusers/seed_integration_test.go new file mode 100644 index 0000000..d997b06 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/internal/testusers/seed_integration_test.go @@ -0,0 +1,132 @@ +package testusers + +import ( + "context" + "database/sql" + "fmt" + "net" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/go-sql-driver/mysql" +) + +// This test never connects to the application database. It creates its own +// uniquely named schema on an explicitly supplied loopback MySQL connection. +func isolatedMySQL(t *testing.T) *sql.DB { + t.Helper() + dsn := os.Getenv("IM_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("set IM_TEST_MYSQL_DSN to enable isolated local MySQL integration tests") + } + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + t.Fatal("invalid test MySQL DSN") + } + host, _, err := net.SplitHostPort(cfg.Addr) + if err != nil || cfg.Net != "tcp" || (host != "127.0.0.1" && host != "localhost" && host != "::1") || cfg.DBName != "" { + t.Fatal("integration tests require a loopback TCP DSN without a database name") + } + cfg.ParseTime = true + admin, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { admin.Close() }) + database := fmt.Sprintf("im_fixture_test_%d", time.Now().UnixNano()) + if !regexp.MustCompile(`^im_fixture_test_[0-9]+$`).MatchString(database) { + t.Fatal("unsafe isolated database name") + } + if _, err = admin.Exec("CREATE DATABASE `" + database + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil { + t.Fatal(err) + } + // Only drop the exact schema successfully created by this test. + t.Cleanup(func() { + if _, err := admin.Exec("DROP DATABASE `" + database + "`"); err != nil { + t.Errorf("cleanup isolated schema %s: %v", database, err) + } + }) + cfg.DBName, cfg.MultiStatements = database, true + db, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + for _, name := range []string{"001_users.sql", "028_test_users.sql"} { + data, err := os.ReadFile(filepath.Join("..", "..", "migrations", name)) + if err != nil { + t.Fatal(err) + } + if _, err = db.Exec(string(data)); err != nil { + t.Fatal(err) + } + } + return db +} + +func TestMySQLSeedRepeatabilityAndIsolation(t *testing.T) { + db := isolatedMySQL(t) + var database string + if err := db.QueryRow(`SELECT DATABASE()`).Scan(&database); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO users (public_id,password_hash) VALUES ('REAL_FIXTURE','sentinel')`); err != nil { + t.Fatal(err) + } + ctx := context.Background() + if _, err := Seed(ctx, db, "https://example.com/uploads", "wrong_database"); err == nil { + t.Fatal("missing target-database protection") + } + first, err := Seed(ctx, db, "https://example.com/uploads", database) + if err != nil || first.Created != 100 || first.Male != 50 || first.Female != 50 || len(first.IDs) != 100 { + t.Fatalf("first seed: %+v, %v", first, err) + } + second, err := Seed(ctx, db, "https://example.com/uploads", database) + if err != nil || second.Created != 0 || second.Skipped != 100 || second.IDs[0] != first.IDs[0] { + t.Fatalf("repeat seed: %+v, %v", second, err) + } + for query, want := range map[string]int{ + `SELECT COUNT(*) FROM users WHERE is_test=0 AND test_batch='' AND password_hash='sentinel'`: 1, + `SELECT COUNT(*) FROM users WHERE is_test=1 AND phone_hash IS NULL AND phone_cipher IS NULL AND password_hash='!TEST_PROFILE_NO_LOGIN'`: 100, + `SELECT COUNT(*) FROM user_profiles WHERE last_active_at IS NULL AND is_vip=0 AND vip_level=0`: 100, + `SELECT COUNT(*) FROM user_profiles WHERE gender=1`: 50, + `SELECT COUNT(*) FROM user_profiles WHERE gender=2`: 50, + `SELECT COUNT(*) FROM user_sessions`: 0, + } { + var got int + if err := db.QueryRow(query).Scan(&got); err != nil || got != want { + t.Errorf("query %s: got %d want %d err %v", query, got, want, err) + } + } + if _, err := db.Exec(`UPDATE users SET test_batch='changed' WHERE public_id='TESTCN000100'`); err != nil { + t.Fatal(err) + } + if _, err := Seed(ctx, db, "https://example.com/uploads", database); err == nil || !strings.Contains(err.Error(), "expected 0 or 100") { + t.Fatalf("partial batch should not be silently repaired: %v", err) + } +} + +func TestMySQLSeedRollsBackOnRealUserCollision(t *testing.T) { + db := isolatedMySQL(t) + var database string + if err := db.QueryRow(`SELECT DATABASE()`).Scan(&database); err != nil { + t.Fatal(err) + } + // Force a late collision to prove earlier inserts in this batch roll back. + if _, err := db.Exec(`INSERT INTO users (public_id,password_hash) VALUES ('TESTCN000099','real-user-sentinel')`); err != nil { + t.Fatal(err) + } + if _, err := Seed(context.Background(), db, "https://example.com/uploads", database); err == nil { + t.Fatal("real-user collision must fail") + } + var total, tests, profiles int + _ = db.QueryRow(`SELECT COUNT(*),COALESCE(SUM(is_test),0) FROM users`).Scan(&total, &tests) + _ = db.QueryRow(`SELECT COUNT(*) FROM user_profiles`).Scan(&profiles) + if total != 1 || tests != 0 || profiles != 0 { + t.Fatalf("partial seed survived rollback: users=%d tests=%d profiles=%d", total, tests, profiles) + } +} diff --git a/im/.deploy/application-20260831-120612/backend/migrations/001_users.sql b/im/.deploy/application-20260831-120612/backend/migrations/001_users.sql new file mode 100644 index 0000000..03659f5 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/001_users.sql @@ -0,0 +1,123 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + public_id VARCHAR(20) NOT NULL, + country_code VARCHAR(8) NOT NULL DEFAULT '+86', + phone_hash BINARY(32) NULL, + phone_cipher VARBINARY(255) NULL, + password_hash VARCHAR(255) NOT NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + risk_level TINYINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_users_public_id (public_id), + UNIQUE KEY uk_users_phone_hash (phone_hash), + KEY idx_users_status_created (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_profiles ( + user_id BIGINT UNSIGNED NOT NULL, + nickname VARCHAR(50) NOT NULL, + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + cover_url VARCHAR(500) NOT NULL DEFAULT '', + gender TINYINT UNSIGNED NOT NULL DEFAULT 0, + birthday DATE NULL, + height_cm SMALLINT UNSIGNED NULL, + city_code VARCHAR(20) NOT NULL DEFAULT '', + city_name VARCHAR(50) NOT NULL DEFAULT '', + occupation VARCHAR(100) NOT NULL DEFAULT '', + education TINYINT UNSIGNED NOT NULL DEFAULT 0, + relationship_status TINYINT UNSIGNED NOT NULL DEFAULT 0, + bio VARCHAR(500) NOT NULL DEFAULT '', + profile_score SMALLINT UNSIGNED NOT NULL DEFAULT 0, + is_vip TINYINT UNSIGNED NOT NULL DEFAULT 0, + vip_level TINYINT UNSIGNED NOT NULL DEFAULT 0, + last_active_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_profiles_city_active (city_code, last_active_at), + CONSTRAINT fk_profiles_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_privacy_settings ( + user_id BIGINT UNSIGNED NOT NULL, + nearby_visible TINYINT(1) NOT NULL DEFAULT 1, + distance_visible TINYINT(1) NOT NULL DEFAULT 1, + online_visible TINYINT(1) NOT NULL DEFAULT 1, + last_active_visible TINYINT(1) NOT NULL DEFAULT 1, + allow_stranger_message TINYINT(1) NOT NULL DEFAULT 1, + allow_profile_visit_record TINYINT(1) NOT NULL DEFAULT 1, + allow_search TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + CONSTRAINT fk_privacy_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_devices ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + device_id VARCHAR(100) NOT NULL, + platform VARCHAR(20) NOT NULL, + device_model VARCHAR(100) NOT NULL DEFAULT '', + os_version VARCHAR(50) NOT NULL DEFAULT '', + app_version VARCHAR(30) NOT NULL DEFAULT '', + push_provider VARCHAR(30) NOT NULL DEFAULT '', + push_token VARCHAR(255) NOT NULL DEFAULT '', + last_ip VARCHAR(45) NOT NULL DEFAULT '', + last_active_at DATETIME(3) NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_device_user_device (user_id, device_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_sessions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + device_id VARCHAR(100) NOT NULL, + refresh_token_hash BINARY(32) NOT NULL, + expires_at DATETIME(3) NOT NULL, + last_active_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + revoked_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sessions_user (user_id, revoked_at), + UNIQUE KEY uk_sessions_refresh (refresh_token_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tags ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + category VARCHAR(30) NOT NULL, + name VARCHAR(50) NOT NULL, + icon VARCHAR(100) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_tags_category_name (category, name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_tags ( + user_id BIGINT UNSIGNED NOT NULL, + tag_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, tag_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + username VARCHAR(50) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + real_name VARCHAR(50) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + last_login_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_admin_username (username) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/application-20260831-120612/backend/migrations/002_social.sql b/im/.deploy/application-20260831-120612/backend/migrations/002_social.sql new file mode 100644 index 0000000..1944c57 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/002_social.sql @@ -0,0 +1,65 @@ +CREATE TABLE IF NOT EXISTS user_follows ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + target_user_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_follow_pair (user_id, target_user_id), + KEY idx_follow_target (target_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_likes ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + target_user_id BIGINT UNSIGNED NOT NULL, + source VARCHAR(30) NOT NULL DEFAULT 'discover', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_like_pair (user_id, target_user_id), + KEY idx_like_target (target_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_matches ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user1_id BIGINT UNSIGNED NOT NULL, + user2_id BIGINT UNSIGNED NOT NULL, + matched_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + PRIMARY KEY (id), + UNIQUE KEY uk_match_pair (user1_id, user2_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_blocks ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + blocked_user_id BIGINT UNSIGNED NOT NULL, + reason VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_block_pair (user_id, blocked_user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS profile_visits ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + viewer_user_id BIGINT UNSIGNED NOT NULL, + target_user_id BIGINT UNSIGNED NOT NULL, + source VARCHAR(30) NOT NULL DEFAULT 'profile', + visited_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_visit_target_time (target_user_id, visited_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_location_states ( + user_id BIGINT UNSIGNED NOT NULL, + city_code VARCHAR(20) NOT NULL, + location_cell VARCHAR(32) NOT NULL DEFAULT '', + latitude DECIMAL(10,7) NULL, + longitude DECIMAL(10,7) NULL, + last_location_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + source VARCHAR(20) NOT NULL DEFAULT 'gps', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_location_city_time (city_code, last_location_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/application-20260831-120612/backend/migrations/003_feed.sql b/im/.deploy/application-20260831-120612/backend/migrations/003_feed.sql new file mode 100644 index 0000000..28a9be2 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/003_feed.sql @@ -0,0 +1,72 @@ +CREATE TABLE IF NOT EXISTS media_assets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + owner_user_id BIGINT UNSIGNED NOT NULL, + media_type VARCHAR(20) NOT NULL, + storage_provider VARCHAR(20) NOT NULL DEFAULT 'local', + bucket VARCHAR(100) NOT NULL DEFAULT '', + object_key VARCHAR(500) NOT NULL DEFAULT '', + public_url VARCHAR(500) NOT NULL DEFAULT '', + mime_type VARCHAR(100) NOT NULL DEFAULT '', + file_size BIGINT UNSIGNED NOT NULL DEFAULT 0, + width INT UNSIGNED NULL, + height INT UNSIGNED NULL, + duration_ms INT UNSIGNED NULL, + moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 1, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS posts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + content VARCHAR(2000) NOT NULL DEFAULT '', + visibility TINYINT UNSIGNED NOT NULL DEFAULT 1, + city_code VARCHAR(20) NOT NULL DEFAULT '', + location_text VARCHAR(100) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 1, + like_count INT UNSIGNED NOT NULL DEFAULT 0, + comment_count INT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id), + KEY idx_posts_status_created (status, created_at), + KEY idx_posts_user_created (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS post_media ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + post_id BIGINT UNSIGNED NOT NULL, + media_id BIGINT UNSIGNED NULL, + media_url VARCHAR(500) NOT NULL DEFAULT '', + media_type VARCHAR(20) NOT NULL DEFAULT 'image', + sort_order INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY idx_post_media_post (post_id, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS post_likes ( + post_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (post_id, user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS post_comments ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + post_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + parent_comment_id BIGINT UNSIGNED NULL, + reply_user_id BIGINT UNSIGNED NULL, + content VARCHAR(1000) NOT NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id), + KEY idx_comments_post_created (post_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/application-20260831-120612/backend/migrations/004_im.sql b/im/.deploy/application-20260831-120612/backend/migrations/004_im.sql new file mode 100644 index 0000000..63b090c --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/004_im.sql @@ -0,0 +1,68 @@ +CREATE TABLE IF NOT EXISTS im_conversations ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + conversation_type TINYINT UNSIGNED NOT NULL DEFAULT 1, + last_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + last_message_id BIGINT UNSIGNED NULL, + last_message_at DATETIME(3) NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_conversation_last (last_message_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_direct_conversations ( + conversation_id BIGINT UNSIGNED NOT NULL, + user1_id BIGINT UNSIGNED NOT NULL, + user2_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (conversation_id), + UNIQUE KEY uk_direct_pair (user1_id, user2_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_conversation_members ( + conversation_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + join_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + read_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + delivered_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + clear_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + pinned TINYINT(1) NOT NULL DEFAULT 0, + muted TINYINT(1) NOT NULL DEFAULT 0, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + joined_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (conversation_id, user_id), + KEY idx_member_user (user_id, updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_messages ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + conversation_id BIGINT UNSIGNED NOT NULL, + seq BIGINT UNSIGNED NOT NULL, + sender_id BIGINT UNSIGNED NOT NULL, + client_msg_id CHAR(26) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + message_type SMALLINT UNSIGNED NOT NULL, + body MEDIUMBLOB NOT NULL, + reply_to_message_id BIGINT UNSIGNED NULL, + moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 1, + recalled_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_conv_seq (conversation_id, seq), + UNIQUE KEY uk_sender_client_msg (sender_id, client_msg_id), + KEY idx_conv_created (conversation_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_user_sync_events ( + user_id BIGINT UNSIGNED NOT NULL, + event_seq BIGINT UNSIGNED NOT NULL, + event_type SMALLINT UNSIGNED NOT NULL, + conversation_id BIGINT UNSIGNED NOT NULL, + message_seq BIGINT UNSIGNED NOT NULL, + event_data MEDIUMBLOB NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, event_seq), + KEY idx_sync_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/application-20260831-120612/backend/migrations/005_membership.sql b/im/.deploy/application-20260831-120612/backend/migrations/005_membership.sql new file mode 100644 index 0000000..ed854a3 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/005_membership.sql @@ -0,0 +1,80 @@ +CREATE TABLE IF NOT EXISTS membership_plans ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + code VARCHAR(30) NOT NULL, + name VARCHAR(50) NOT NULL, + level TINYINT UNSIGNED NOT NULL, + duration_days INT UNSIGNED NOT NULL, + price_cent INT UNSIGNED NOT NULL, + original_price_cent INT UNSIGNED NOT NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_plan_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS benefit_definitions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + benefit_key VARCHAR(50) NOT NULL, + name VARCHAR(100) NOT NULL, + value_type VARCHAR(20) NOT NULL, + description VARCHAR(255) NOT NULL DEFAULT '', + PRIMARY KEY (id), + UNIQUE KEY uk_benefit_key (benefit_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS plan_benefits ( + plan_id BIGINT UNSIGNED NOT NULL, + benefit_id BIGINT UNSIGNED NOT NULL, + benefit_value VARCHAR(255) NOT NULL, + PRIMARY KEY (plan_id, benefit_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS subscriptions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + plan_id BIGINT UNSIGNED NOT NULL, + source VARCHAR(30) NOT NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + started_at DATETIME(3) NOT NULL, + expires_at DATETIME(3) NOT NULL, + auto_renew TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sub_user_expire (user_id, expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_entitlements ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + benefit_key VARCHAR(50) NOT NULL, + benefit_value VARCHAR(255) NOT NULL, + source_type VARCHAR(30) NOT NULL, + source_id BIGINT UNSIGNED NOT NULL, + started_at DATETIME(3) NOT NULL, + expires_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_entitlement_user_key (user_id, benefit_key, expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS orders ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_no VARCHAR(40) NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + product_type VARCHAR(30) NOT NULL, + product_id BIGINT UNSIGNED NOT NULL, + amount_cent INT UNSIGNED NOT NULL, + currency CHAR(3) NOT NULL DEFAULT 'CNY', + status VARCHAR(20) NOT NULL DEFAULT 'CREATED', + channel VARCHAR(30) NOT NULL DEFAULT '', + paid_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_order_no (order_no), + KEY idx_order_user_created (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/application-20260831-120612/backend/migrations/006_moderation.sql b/im/.deploy/application-20260831-120612/backend/migrations/006_moderation.sql new file mode 100644 index 0000000..887eec5 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/006_moderation.sql @@ -0,0 +1,72 @@ +CREATE TABLE IF NOT EXISTS reports ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + reporter_user_id BIGINT UNSIGNED NOT NULL, + target_type VARCHAR(30) NOT NULL, + target_id BIGINT UNSIGNED NOT NULL, + reason_code VARCHAR(50) NOT NULL, + description VARCHAR(1000) NOT NULL DEFAULT '', + evidence_json JSON NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + handled_by BIGINT UNSIGNED NULL, + handled_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_reports_status_created (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS moderation_tasks ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + target_type VARCHAR(30) NOT NULL, + target_id BIGINT UNSIGNED NOT NULL, + content_type VARCHAR(30) NOT NULL, + risk_score INT NOT NULL DEFAULT 0, + machine_result JSON NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + reviewer_id BIGINT UNSIGNED NULL, + review_result VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + reviewed_at DATETIME(3) NULL, + PRIMARY KEY (id), + KEY idx_moderation_status_created (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_risk_profiles ( + user_id BIGINT UNSIGNED NOT NULL, + risk_score INT NOT NULL DEFAULT 0, + risk_level TINYINT UNSIGNED NOT NULL DEFAULT 0, + message_score INT NOT NULL DEFAULT 0, + device_score INT NOT NULL DEFAULT 0, + report_score INT NOT NULL DEFAULT 0, + behavior_score INT NOT NULL DEFAULT 0, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_risk_level_score (risk_level, risk_score) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS risk_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + event_type VARCHAR(50) NOT NULL, + score_delta INT NOT NULL, + device_id VARCHAR(100) NOT NULL DEFAULT '', + ip VARCHAR(45) NOT NULL DEFAULT '', + metadata JSON NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_risk_event_user_created (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS notifications ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + type VARCHAR(30) NOT NULL, + title VARCHAR(100) NOT NULL, + content VARCHAR(1000) NOT NULL, + biz_type VARCHAR(30) NOT NULL DEFAULT '', + biz_id BIGINT UNSIGNED NULL, + read_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_notify_user_read_created (user_id, read_at, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/application-20260831-120612/backend/migrations/007_system.sql b/im/.deploy/application-20260831-120612/backend/migrations/007_system.sql new file mode 100644 index 0000000..850e6f0 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/007_system.sql @@ -0,0 +1,70 @@ +CREATE TABLE IF NOT EXISTS system_configs ( + config_key VARCHAR(100) NOT NULL, + config_value TEXT NOT NULL, + value_type VARCHAR(20) NOT NULL DEFAULT 'string', + description VARCHAR(255) NOT NULL DEFAULT '', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (config_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS banners ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + title VARCHAR(100) NOT NULL, + image_url VARCHAR(500) NOT NULL, + link_url VARCHAR(500) NOT NULL DEFAULT '', + position VARCHAR(30) NOT NULL DEFAULT 'home', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + starts_at DATETIME(3) NULL, + ends_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS app_versions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + platform VARCHAR(20) NOT NULL, + version VARCHAR(30) NOT NULL, + build_number INT UNSIGNED NOT NULL, + force_update TINYINT(1) NOT NULL DEFAULT 0, + download_url VARCHAR(500) NOT NULL DEFAULT '', + release_notes VARCHAR(2000) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_version_platform_build (platform, build_number) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_audit_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + admin_user_id BIGINT UNSIGNED NOT NULL, + action VARCHAR(100) NOT NULL, + target_type VARCHAR(50) NOT NULL DEFAULT '', + target_id BIGINT UNSIGNED NULL, + request_data JSON NULL, + ip VARCHAR(45) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_audit_admin_created (admin_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('im.recall_seconds', '120', 'number', '消息撤回时间窗口'), + ('nearby.max_distance_km', '50', 'number', '附近的人最大距离'), + ('stranger.daily_limit', '10', 'number', '普通用户每日主动聊天人数') +ON DUPLICATE KEY UPDATE description = VALUES(description); + +INSERT INTO membership_plans (code, name, level, duration_days, price_cent, original_price_cent, status, sort_order) VALUES + ('VIP_1M', 'VIP 1个月', 1, 30, 2800, 4000, 1, 10), + ('VIP_3M', 'VIP 3个月', 1, 90, 6800, 9000, 1, 20), + ('SVIP_12M', 'SVIP 12个月', 2, 365, 22800, 36000, 1, 30) +ON DUPLICATE KEY UPDATE name = VALUES(name), price_cent = VALUES(price_cent), original_price_cent = VALUES(original_price_cent); + +INSERT INTO tags (category, name, icon, status, sort_order) VALUES + ('personality', '天秤座', '', 1, 10), + ('hobby', '摄影爱好者', '', 1, 20), + ('hobby', '旅行达人', '', 1, 30), + ('hobby', '电影', '', 1, 40), + ('hobby', '音乐', '', 1, 50) +ON DUPLICATE KEY UPDATE status = VALUES(status); + diff --git a/im/.deploy/application-20260831-120612/backend/migrations/008_integrations.sql b/im/.deploy/application-20260831-120612/backend/migrations/008_integrations.sql new file mode 100644 index 0000000..e1d902e --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/008_integrations.sql @@ -0,0 +1,37 @@ +CREATE TABLE IF NOT EXISTS sms_verification_codes ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + phone_hash BINARY(32) NOT NULL, + scene VARCHAR(30) NOT NULL, + code_hash BINARY(32) NOT NULL, + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sms_phone_scene_created (phone_hash, scene, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('sms.enabled', 'true', 'boolean', '是否启用短信服务'), + ('sms.provider', 'debug', 'string', '短信提供商:debug 或 webhook'), + ('sms.sign_name', '星遇社交', 'string', '短信签名'), + ('sms.template_register', 'REGISTER', 'string', '注册验证码模板 ID'), + ('sms.template_login', 'LOGIN', 'string', '登录验证码模板 ID'), + ('sms.template_reset', 'RESET', 'string', '找回密码模板 ID'), + ('sms.webhook_url', '', 'string', '短信网关 Webhook 地址'), + ('sms.webhook_token', '', 'secret', '短信网关鉴权令牌'), + ('sms.debug_code', '123456', 'secret', '本地调试验证码'), + ('sms.expire_seconds', '300', 'number', '验证码有效期(秒)'), + ('payment.mode', 'sandbox', 'string', '支付模式:sandbox 或 live'), + ('payment.alipay.enabled', 'true', 'boolean', '是否启用支付宝'), + ('payment.alipay.app_id', '', 'string', '支付宝应用 APPID'), + ('payment.alipay.private_key', '', 'secret', '支付宝应用私钥'), + ('payment.alipay.public_key', '', 'secret', '支付宝公钥'), + ('payment.alipay.notify_url', '', 'string', '支付宝异步通知地址'), + ('payment.wechat.enabled', 'true', 'boolean', '是否启用微信支付'), + ('payment.wechat.app_id', '', 'string', '微信支付 AppID'), + ('payment.wechat.mch_id', '', 'string', '微信支付商户号'), + ('payment.wechat.api_v3_key', '', 'secret', '微信支付 APIv3 密钥'), + ('payment.wechat.private_key', '', 'secret', '微信支付商户私钥'), + ('payment.wechat.serial_no', '', 'string', '微信支付证书序列号'), + ('payment.wechat.notify_url', '', 'string', '微信支付回调地址') +ON DUPLICATE KEY UPDATE description = VALUES(description), value_type = VALUES(value_type); diff --git a/im/.deploy/application-20260831-120612/backend/migrations/009_repair_utf8_defaults.sql b/im/.deploy/application-20260831-120612/backend/migrations/009_repair_utf8_defaults.sql new file mode 100644 index 0000000..d40b76f --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/009_repair_utf8_defaults.sql @@ -0,0 +1,6 @@ +-- PowerShell 5 may encode text piped to native executables as the active ANSI +-- code page. Repair the only user-visible non-ASCII integration default for +-- databases initialized by the earlier migration runner. +UPDATE system_configs +SET config_value = CONVERT(0xE6989FE98187E7A4BEE4BAA4 USING utf8mb4) +WHERE config_key = 'sms.sign_name' AND config_value = '????'; diff --git a/im/.deploy/application-20260831-120612/backend/migrations/010_admin_operations.sql b/im/.deploy/application-20260831-120612/backend/migrations/010_admin_operations.sql new file mode 100644 index 0000000..4fea041 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/010_admin_operations.sql @@ -0,0 +1,64 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS user_verifications ( + user_id BIGINT UNSIGNED NOT NULL, + verification_type VARCHAR(30) NOT NULL DEFAULT 'real_name', + status VARCHAR(20) NOT NULL DEFAULT 'UNVERIFIED', + real_name VARCHAR(50) NOT NULL DEFAULT '', + document_mask VARCHAR(80) NOT NULL DEFAULT '', + remark VARCHAR(500) NOT NULL DEFAULT '', + reviewer_admin_id BIGINT UNSIGNED NULL, + submitted_at DATETIME(3) NULL, + reviewed_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_verification_status_updated (status, updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_sanctions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + sanction_type VARCHAR(30) NOT NULL, + reason VARCHAR(500) NOT NULL, + starts_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + expires_at DATETIME(3) NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + operator_admin_id BIGINT UNSIGNED NOT NULL, + revoked_by BIGINT UNSIGNED NULL, + revoked_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sanction_user_status_expire (user_id, status, expires_at), + KEY idx_sanction_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_security_controls ( + user_id BIGINT UNSIGNED NOT NULL, + token_version INT UNSIGNED NOT NULL DEFAULT 0, + force_logout_at DATETIME(3) NULL, + password_reset_at DATETIME(3) NULL, + last_operator_admin_id BIGINT UNSIGNED NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +SET @token_version_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_security_controls' AND COLUMN_NAME='token_version'); +SET @token_version_sql=IF(@token_version_exists=0,'ALTER TABLE user_security_controls ADD COLUMN token_version INT UNSIGNED NOT NULL DEFAULT 0 AFTER user_id','SELECT 1'); +PREPARE token_version_stmt FROM @token_version_sql; +EXECUTE token_version_stmt; +DEALLOCATE PREPARE token_version_stmt; + +INSERT INTO user_verifications (user_id, status) +SELECT u.id, 'UNVERIFIED' FROM users u +LEFT JOIN user_verifications v ON v.user_id=u.id +WHERE v.user_id IS NULL; + +INSERT INTO subscriptions (user_id, plan_id, source, status, started_at, expires_at) +SELECT p.user_id, mp.id, 'legacy_backfill', 1, NOW(3), DATE_ADD(NOW(3), INTERVAL mp.duration_days DAY) +FROM user_profiles p +JOIN membership_plans mp ON mp.level=p.vip_level + AND mp.duration_days=(SELECT MAX(mp2.duration_days) FROM membership_plans mp2 WHERE mp2.level=p.vip_level) +WHERE p.is_vip=1 AND p.vip_level>0 + AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.user_id=p.user_id AND s.status=1 AND s.expires_at>NOW(3)); diff --git a/im/.deploy/application-20260831-120612/backend/migrations/011_commerce_message_admin.sql b/im/.deploy/application-20260831-120612/backend/migrations/011_commerce_message_admin.sql new file mode 100644 index 0000000..8d7fef8 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/011_commerce_message_admin.sql @@ -0,0 +1,25 @@ +SET NAMES utf8mb4; + +SET @plan_deleted_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='membership_plans' AND COLUMN_NAME='deleted_at'); +SET @plan_deleted_sql=IF(@plan_deleted_exists=0,'ALTER TABLE membership_plans ADD COLUMN deleted_at DATETIME(3) NULL AFTER updated_at','SELECT 1'); +PREPARE plan_deleted_stmt FROM @plan_deleted_sql; +EXECUTE plan_deleted_stmt; +DEALLOCATE PREPARE plan_deleted_stmt; + +SET @order_deleted_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='deleted_at'); +SET @order_deleted_sql=IF(@order_deleted_exists=0,'ALTER TABLE orders ADD COLUMN deleted_at DATETIME(3) NULL AFTER updated_at','SELECT 1'); +PREPARE order_deleted_stmt FROM @order_deleted_sql; +EXECUTE order_deleted_stmt; +DEALLOCATE PREPARE order_deleted_stmt; + +SET @plan_deleted_index_exists=(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='membership_plans' AND INDEX_NAME='idx_plan_deleted_sort'); +SET @plan_deleted_index_sql=IF(@plan_deleted_index_exists=0,'ALTER TABLE membership_plans ADD KEY idx_plan_deleted_sort (deleted_at,sort_order)','SELECT 1'); +PREPARE plan_deleted_index_stmt FROM @plan_deleted_index_sql; +EXECUTE plan_deleted_index_stmt; +DEALLOCATE PREPARE plan_deleted_index_stmt; + +SET @order_deleted_index_exists=(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND INDEX_NAME='idx_order_deleted_created'); +SET @order_deleted_index_sql=IF(@order_deleted_index_exists=0,'ALTER TABLE orders ADD KEY idx_order_deleted_created (deleted_at,created_at)','SELECT 1'); +PREPARE order_deleted_index_stmt FROM @order_deleted_index_sql; +EXECUTE order_deleted_index_stmt; +DEALLOCATE PREPARE order_deleted_index_stmt; diff --git a/im/.deploy/application-20260831-120612/backend/migrations/012_expand_client_message_id.sql b/im/.deploy/application-20260831-120612/backend/migrations/012_expand_client_message_id.sql new file mode 100644 index 0000000..8010cc5 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/012_expand_client_message_id.sql @@ -0,0 +1,6 @@ +SET NAMES utf8mb4; + +-- Accept both the canonical 26-character client ID and UUID-style IDs from +-- older/cached clients while preserving sender-level idempotency. +ALTER TABLE im_messages + MODIFY COLUMN client_msg_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL; diff --git a/im/.deploy/application-20260831-120612/backend/migrations/013_security_hardening.sql b/im/.deploy/application-20260831-120612/backend/migrations/013_security_hardening.sql new file mode 100644 index 0000000..543c563 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/013_security_hardening.sql @@ -0,0 +1,12 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS api_rate_limits ( + bucket_key BINARY(32) NOT NULL, + action_name VARCHAR(40) NOT NULL, + hits INT UNSIGNED NOT NULL DEFAULT 1, + expires_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (bucket_key), + KEY idx_rate_limit_expires (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/im/.deploy/application-20260831-120612/backend/migrations/014_payment_gateway.sql b/im/.deploy/application-20260831-120612/backend/migrations/014_payment_gateway.sql new file mode 100644 index 0000000..6a21487 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/014_payment_gateway.sql @@ -0,0 +1,61 @@ +SET NAMES utf8mb4; + +SET @provider_order_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='provider_order_no'); +SET @provider_order_sql=IF(@provider_order_exists=0,'ALTER TABLE orders ADD COLUMN provider_order_no VARCHAR(100) NOT NULL DEFAULT '''' AFTER channel','SELECT 1'); +PREPARE provider_order_stmt FROM @provider_order_sql; +EXECUTE provider_order_stmt; +DEALLOCATE PREPARE provider_order_stmt; + +SET @checkout_url_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='checkout_url'); +SET @checkout_url_sql=IF(@checkout_url_exists=0,'ALTER TABLE orders ADD COLUMN checkout_url VARCHAR(1000) NOT NULL DEFAULT '''' AFTER provider_order_no','SELECT 1'); +PREPARE checkout_url_stmt FROM @checkout_url_sql; +EXECUTE checkout_url_stmt; +DEALLOCATE PREPARE checkout_url_stmt; + +SET @payment_payload_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='payment_payload'); +SET @payment_payload_sql=IF(@payment_payload_exists=0,'ALTER TABLE orders ADD COLUMN payment_payload MEDIUMTEXT NULL AFTER checkout_url','SELECT 1'); +PREPARE payment_payload_stmt FROM @payment_payload_sql; +EXECUTE payment_payload_stmt; +DEALLOCATE PREPARE payment_payload_stmt; + +SET @paid_amount_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='paid_amount_cent'); +SET @paid_amount_sql=IF(@paid_amount_exists=0,'ALTER TABLE orders ADD COLUMN paid_amount_cent INT UNSIGNED NULL AFTER amount_cent','SELECT 1'); +PREPARE paid_amount_stmt FROM @paid_amount_sql; +EXECUTE paid_amount_stmt; +DEALLOCATE PREPARE paid_amount_stmt; + +SET @payment_notified_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='payment_notified_at'); +SET @payment_notified_sql=IF(@payment_notified_exists=0,'ALTER TABLE orders ADD COLUMN payment_notified_at DATETIME(3) NULL AFTER paid_at','SELECT 1'); +PREPARE payment_notified_stmt FROM @payment_notified_sql; +EXECUTE payment_notified_stmt; +DEALLOCATE PREPARE payment_notified_stmt; + +SET @provider_order_index_exists=(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND INDEX_NAME='idx_order_provider_no'); +SET @provider_order_index_sql=IF(@provider_order_index_exists=0,'ALTER TABLE orders ADD KEY idx_order_provider_no (provider_order_no)','SELECT 1'); +PREPARE provider_order_index_stmt FROM @provider_order_index_sql; +EXECUTE provider_order_index_stmt; +DEALLOCATE PREPARE provider_order_index_stmt; + +CREATE TABLE IF NOT EXISTS payment_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + event_id VARCHAR(100) NOT NULL, + order_no VARCHAR(40) NOT NULL, + channel VARCHAR(30) NOT NULL, + provider_order_no VARCHAR(100) NOT NULL DEFAULT '', + event_status VARCHAR(30) NOT NULL, + amount_cent INT UNSIGNED NOT NULL DEFAULT 0, + raw_payload MEDIUMTEXT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_payment_event (event_id), + KEY idx_payment_event_order (order_no,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key,config_value,value_type,description) VALUES + ('payment.gateway.create_url','','string','统一支付网关创建支付地址'), + ('payment.gateway.token','','secret','统一支付网关 Bearer Token'), + ('payment.gateway.notify_secret','','secret','支付通知 HMAC-SHA256 密钥'), + ('payment.gateway.notify_url','','string','本系统支付通知公网 HTTPS 地址'), + ('payment.gateway.return_url','','string','支付完成后的客户端返回地址'), + ('payment.gateway.timeout_seconds','10','number','支付网关请求超时秒数') +ON DUPLICATE KEY UPDATE description=VALUES(description),value_type=VALUES(value_type); diff --git a/im/.deploy/application-20260831-120612/backend/migrations/015_payment_refunds.sql b/im/.deploy/application-20260831-120612/backend/migrations/015_payment_refunds.sql new file mode 100644 index 0000000..d5bfc53 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/015_payment_refunds.sql @@ -0,0 +1,5 @@ +SET NAMES utf8mb4; + +INSERT INTO system_configs (config_key,config_value,value_type,description) VALUES + ('payment.gateway.refund_url','','string','统一支付网关退款申请地址') +ON DUPLICATE KEY UPDATE description=VALUES(description),value_type=VALUES(value_type); diff --git a/im/.deploy/application-20260831-120612/backend/migrations/016_admin_password_security.sql b/im/.deploy/application-20260831-120612/backend/migrations/016_admin_password_security.sql new file mode 100644 index 0000000..0f0ffc7 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/016_admin_password_security.sql @@ -0,0 +1,6 @@ +SET NAMES utf8mb4; + +ALTER TABLE admin_users + ADD COLUMN token_version INT UNSIGNED NOT NULL DEFAULT 0 AFTER status, + ADD COLUMN password_changed_at DATETIME(3) NULL AFTER last_login_at; + diff --git a/im/.deploy/application-20260831-120612/backend/migrations/017_sms_cloud_providers.sql b/im/.deploy/application-20260831-120612/backend/migrations/017_sms_cloud_providers.sql new file mode 100644 index 0000000..cb1a9d9 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/017_sms_cloud_providers.sql @@ -0,0 +1,39 @@ +SET NAMES utf8mb4; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('sms.aliyun.endpoint', 'https://dysmsapi.aliyuncs.com', 'string', '阿里云短信 API 地址'), + ('sms.aliyun.access_key_id', '', 'secret', '阿里云 AccessKey ID'), + ('sms.aliyun.access_key_secret', '', 'secret', '阿里云 AccessKey Secret'), + ('sms.aliyun.sign_name', '', 'string', '阿里云短信签名'), + ('sms.aliyun.template_register', '', 'string', '阿里云注册模板 Code'), + ('sms.aliyun.template_login', '', 'string', '阿里云登录模板 Code'), + ('sms.aliyun.template_reset', '', 'string', '阿里云重置密码模板 Code'), + ('sms.aliyun.template_params', '{"code":"{{code}}"}', 'string', '阿里云模板变量 JSON'), + + ('sms.tencent.endpoint', 'https://sms.tencentcloudapi.com', 'string', '腾讯云短信 API 地址'), + ('sms.tencent.secret_id', '', 'secret', '腾讯云 SecretId'), + ('sms.tencent.secret_key', '', 'secret', '腾讯云 SecretKey'), + ('sms.tencent.sdk_app_id', '', 'string', '腾讯云短信 SdkAppId'), + ('sms.tencent.region', 'ap-guangzhou', 'string', '腾讯云短信地域'), + ('sms.tencent.sign_name', '', 'string', '腾讯云短信签名'), + ('sms.tencent.template_register', '', 'string', '腾讯云注册模板 ID'), + ('sms.tencent.template_login', '', 'string', '腾讯云登录模板 ID'), + ('sms.tencent.template_reset', '', 'string', '腾讯云重置密码模板 ID'), + ('sms.tencent.template_params', '["{{code}}"]', 'string', '腾讯云模板参数 JSON'), + + ('sms.huawei.endpoint', '', 'string', '华为云短信 APP 接入地址'), + ('sms.huawei.app_key', '', 'secret', '华为云短信 Application Key'), + ('sms.huawei.app_secret', '', 'secret', '华为云短信 Application Secret'), + ('sms.huawei.sender', '', 'string', '华为云短信签名通道号'), + ('sms.huawei.signature', '', 'string', '华为云短信签名名称'), + ('sms.huawei.template_register', '', 'string', '华为云注册模板 ID'), + ('sms.huawei.template_login', '', 'string', '华为云登录模板 ID'), + ('sms.huawei.template_reset', '', 'string', '华为云重置密码模板 ID'), + ('sms.huawei.template_params', '["{{code}}"]', 'string', '华为云模板参数 JSON'), + ('sms.huawei.status_callback', '', 'string', '华为云短信状态回调地址') +ON DUPLICATE KEY UPDATE description = VALUES(description), value_type = VALUES(value_type); + +UPDATE system_configs +SET description = '短信提供商:aliyun、tencent、huawei、webhook 或 debug' +WHERE config_key = 'sms.provider'; + diff --git a/im/.deploy/application-20260831-120612/backend/migrations/018_object_storage_providers.sql b/im/.deploy/application-20260831-120612/backend/migrations/018_object_storage_providers.sql new file mode 100644 index 0000000..9174ca5 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/018_object_storage_providers.sql @@ -0,0 +1,39 @@ +SET NAMES utf8mb4; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('storage.provider', 'local', 'string', '文件存储提供商:local、aliyun_oss、tencent_cos、qiniu、huawei_obs 或 huawei_flexus'), + ('storage.object_prefix', 'media', 'string', '云端对象键前缀'), + + ('storage.local.directory', './uploads', 'string', '本地文件存储目录'), + ('storage.local.public_base_url', '', 'string', '本地文件公开访问地址'), + + ('storage.aliyun_oss.endpoint', 'https://oss-cn-hangzhou.aliyuncs.com', 'string', '阿里云 OSS Endpoint'), + ('storage.aliyun_oss.region', 'cn-hangzhou', 'string', '阿里云 OSS Region'), + ('storage.aliyun_oss.bucket', '', 'string', '阿里云 OSS Bucket'), + ('storage.aliyun_oss.access_key_id', '', 'secret', '阿里云 OSS AccessKey ID'), + ('storage.aliyun_oss.access_key_secret', '', 'secret', '阿里云 OSS AccessKey Secret'), + ('storage.aliyun_oss.public_base_url', '', 'string', '阿里云 OSS 文件访问域名'), + + ('storage.tencent_cos.endpoint', '', 'string', '腾讯云 COS Bucket URL'), + ('storage.tencent_cos.bucket', '', 'string', '腾讯云 COS Bucket'), + ('storage.tencent_cos.secret_id', '', 'secret', '腾讯云 COS SecretId'), + ('storage.tencent_cos.secret_key', '', 'secret', '腾讯云 COS SecretKey'), + ('storage.tencent_cos.public_base_url', '', 'string', '腾讯云 COS 文件访问域名'), + + ('storage.qiniu.bucket', '', 'string', '七牛云 Kodo 空间名称'), + ('storage.qiniu.access_key', '', 'secret', '七牛云 AccessKey'), + ('storage.qiniu.secret_key', '', 'secret', '七牛云 SecretKey'), + ('storage.qiniu.public_base_url', '', 'string', '七牛云文件访问域名'), + + ('storage.huawei_obs.endpoint', 'https://obs.cn-north-4.myhuaweicloud.com', 'string', '华为云 OBS Endpoint'), + ('storage.huawei_obs.bucket', '', 'string', '华为云 OBS Bucket'), + ('storage.huawei_obs.access_key', '', 'secret', '华为云 OBS Access Key'), + ('storage.huawei_obs.secret_key', '', 'secret', '华为云 OBS Secret Key'), + ('storage.huawei_obs.public_base_url', '', 'string', '华为云 OBS 文件访问域名'), + + ('storage.huawei_flexus.endpoint', '', 'string', '华为云 Flexus 对象存储 Endpoint'), + ('storage.huawei_flexus.bucket', '', 'string', '华为云 Flexus 对象存储 Bucket'), + ('storage.huawei_flexus.access_key', '', 'secret', '华为云 Flexus 对象存储 Access Key'), + ('storage.huawei_flexus.secret_key', '', 'secret', '华为云 Flexus 对象存储 Secret Key'), + ('storage.huawei_flexus.public_base_url', '', 'string', '华为云 Flexus 对象存储文件访问域名') +ON DUPLICATE KEY UPDATE description = VALUES(description), value_type = VALUES(value_type); diff --git a/im/.deploy/application-20260831-120612/backend/migrations/019_daily_active_chat_limit.sql b/im/.deploy/application-20260831-120612/backend/migrations/019_daily_active_chat_limit.sql new file mode 100644 index 0000000..24efbb6 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/019_daily_active_chat_limit.sql @@ -0,0 +1,34 @@ +SET @daily_chat_limit_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='membership_plans' AND COLUMN_NAME='daily_active_chat_limit'); +SET @daily_chat_limit_sql=IF(@daily_chat_limit_exists=0,'ALTER TABLE membership_plans ADD COLUMN daily_active_chat_limit INT UNSIGNED NOT NULL DEFAULT 20 AFTER duration_days','SELECT 1'); +PREPARE daily_chat_limit_stmt FROM @daily_chat_limit_sql; +EXECUTE daily_chat_limit_stmt; +DEALLOCATE PREPARE daily_chat_limit_stmt; + +UPDATE membership_plans +SET daily_active_chat_limit=CASE WHEN level>=2 THEN 100 ELSE 20 END +WHERE daily_active_chat_limit=20; + +INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES + ('membership.free_daily_active_chat_limit','5','integer','普通用户每日可主动聊天的不同用户数,0 表示不限制') +ON DUPLICATE KEY UPDATE description=VALUES(description); + +CREATE TABLE IF NOT EXISTS im_daily_active_chat_usage ( + user_id BIGINT UNSIGNED NOT NULL, + usage_date DATE NOT NULL, + used_count INT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id,usage_date), + KEY idx_daily_chat_usage_date (usage_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_daily_active_chat_targets ( + user_id BIGINT UNSIGNED NOT NULL, + target_user_id BIGINT UNSIGNED NOT NULL, + usage_date DATE NOT NULL, + conversation_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id,target_user_id,usage_date), + KEY idx_daily_chat_target_date (target_user_id,usage_date), + KEY idx_daily_chat_conversation (conversation_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/im/.deploy/application-20260831-120612/backend/migrations/020_admin_oauth_login.sql b/im/.deploy/application-20260831-120612/backend/migrations/020_admin_oauth_login.sql new file mode 100644 index 0000000..e93527f --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/020_admin_oauth_login.sql @@ -0,0 +1,87 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS admin_oauth_identities ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + provider VARCHAR(20) NOT NULL, + subject VARCHAR(191) NOT NULL, + admin_user_id BIGINT UNSIGNED NOT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + display_name VARCHAR(100) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + last_login_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_admin_oauth_provider_subject (provider, subject), + UNIQUE KEY uk_admin_oauth_user_provider (admin_user_id, provider), + CONSTRAINT fk_admin_oauth_identity_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_oauth_states ( + state_hash BINARY(32) NOT NULL, + provider VARCHAR(20) NOT NULL, + code_verifier VARCHAR(128) NOT NULL DEFAULT '', + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (state_hash), + KEY idx_admin_oauth_state_expiry (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_oauth_login_codes ( + code_hash BINARY(32) NOT NULL, + provider VARCHAR(20) NOT NULL, + subject VARCHAR(191) NOT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + display_name VARCHAR(100) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + admin_user_id BIGINT UNSIGNED NULL, + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (code_hash), + KEY idx_admin_oauth_code_expiry (expires_at), + KEY idx_admin_oauth_code_admin (admin_user_id), + CONSTRAINT fk_admin_oauth_code_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('oauth.admin.frontend_callback_url', 'http://localhost:5560/auth/social-callback', 'string', '第三方登录完成后跳转的管理端页面;生产环境必须使用 HTTPS'), + + ('oauth.wechat.enabled', 'false', 'boolean', '启用微信扫码登录'), + ('oauth.wechat.client_id', '', 'string', '微信开放平台网站应用 AppID'), + ('oauth.wechat.client_secret', '', 'secret', '微信开放平台网站应用 AppSecret'), + ('oauth.wechat.authorization_url', 'https://open.weixin.qq.com/connect/qrconnect', 'string', '微信登录授权地址'), + ('oauth.wechat.token_url', 'https://api.weixin.qq.com/sns/oauth2/access_token', 'string', '微信登录令牌地址'), + ('oauth.wechat.userinfo_url', 'https://api.weixin.qq.com/sns/userinfo', 'string', '微信用户信息地址'), + ('oauth.wechat.scope', 'snsapi_login', 'string', '微信网站应用登录授权范围'), + ('oauth.wechat.redirect_uri', 'http://127.0.0.1:8888/admin/v1/auth/oauth/callback', 'string', '微信开放平台登记的授权回调地址;生产环境必须使用 HTTPS'), + + ('oauth.qq.enabled', 'false', 'boolean', '启用 QQ 登录'), + ('oauth.qq.client_id', '', 'string', 'QQ 互联应用 AppID'), + ('oauth.qq.client_secret', '', 'secret', 'QQ 互联应用 AppKey'), + ('oauth.qq.authorization_url', 'https://graph.qq.com/oauth2.0/authorize', 'string', 'QQ 登录授权地址'), + ('oauth.qq.token_url', 'https://graph.qq.com/oauth2.0/token', 'string', 'QQ 登录令牌地址'), + ('oauth.qq.openid_url', 'https://graph.qq.com/oauth2.0/me', 'string', 'QQ OpenID 查询地址'), + ('oauth.qq.userinfo_url', 'https://graph.qq.com/user/get_user_info', 'string', 'QQ 用户信息地址'), + ('oauth.qq.scope', 'get_user_info', 'string', 'QQ 登录授权范围'), + ('oauth.qq.redirect_uri', 'http://127.0.0.1:8888/admin/v1/auth/oauth/callback', 'string', 'QQ 互联登记的授权回调地址;生产环境必须使用 HTTPS'), + + ('oauth.github.enabled', 'false', 'boolean', '启用 GitHub 登录'), + ('oauth.github.client_id', '', 'string', 'GitHub OAuth App Client ID'), + ('oauth.github.client_secret', '', 'secret', 'GitHub OAuth App Client Secret'), + ('oauth.github.authorization_url', 'https://github.com/login/oauth/authorize', 'string', 'GitHub OAuth 授权地址'), + ('oauth.github.token_url', 'https://github.com/login/oauth/access_token', 'string', 'GitHub OAuth 令牌地址'), + ('oauth.github.userinfo_url', 'https://api.github.com/user', 'string', 'GitHub 当前用户信息地址'), + ('oauth.github.scope', 'read:user user:email', 'string', 'GitHub 登录最小授权范围'), + ('oauth.github.redirect_uri', 'http://127.0.0.1:8888/admin/v1/auth/oauth/callback', 'string', 'GitHub OAuth App 登记的 callback URL;生产环境必须使用 HTTPS'), + + ('oauth.google.enabled', 'false', 'boolean', '启用 Google 登录'), + ('oauth.google.client_id', '', 'string', 'Google OAuth 2.0 Client ID'), + ('oauth.google.client_secret', '', 'secret', 'Google OAuth 2.0 Client Secret'), + ('oauth.google.authorization_url', 'https://accounts.google.com/o/oauth2/v2/auth', 'string', 'Google OAuth 授权地址'), + ('oauth.google.token_url', 'https://oauth2.googleapis.com/token', 'string', 'Google OAuth 令牌地址'), + ('oauth.google.userinfo_url', 'https://openidconnect.googleapis.com/v1/userinfo', 'string', 'Google OpenID Connect UserInfo 地址'), + ('oauth.google.scope', 'openid profile email', 'string', 'Google 登录授权范围'), + ('oauth.google.redirect_uri', 'http://127.0.0.1:8888/admin/v1/auth/oauth/callback', 'string', 'Google Cloud Console 登记的 redirect URI;生产环境必须使用 HTTPS') +ON DUPLICATE KEY UPDATE description = VALUES(description); diff --git a/im/.deploy/application-20260831-120612/backend/migrations/021_user_oauth_login.sql b/im/.deploy/application-20260831-120612/backend/migrations/021_user_oauth_login.sql new file mode 100644 index 0000000..03e36c1 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/021_user_oauth_login.sql @@ -0,0 +1,54 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS user_oauth_identities ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + provider VARCHAR(20) NOT NULL, + subject VARCHAR(191) NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + display_name VARCHAR(100) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + last_login_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_user_oauth_provider_subject (provider, subject), + UNIQUE KEY uk_user_oauth_user_provider (user_id, provider), + CONSTRAINT fk_user_oauth_identity_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_oauth_states ( + state_hash BINARY(32) NOT NULL, + provider VARCHAR(20) NOT NULL, + code_verifier VARCHAR(128) NOT NULL DEFAULT '', + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (state_hash), + KEY idx_user_oauth_state_expiry (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_oauth_login_codes ( + code_hash BINARY(32) NOT NULL, + provider VARCHAR(20) NOT NULL, + subject VARCHAR(191) NOT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + display_name VARCHAR(100) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + user_id BIGINT UNSIGNED NULL, + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (code_hash), + KEY idx_user_oauth_code_expiry (expires_at), + KEY idx_user_oauth_code_user (user_id), + CONSTRAINT fk_user_oauth_code_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('oauth.user.frontend_callback_url', 'http://localhost:5174/#/pages/auth/oauth-callback', 'string', '第三方登录完成后跳转的 uni-app H5 页面;生产环境必须使用 HTTPS'), + ('oauth.user.wechat.enabled', 'false', 'boolean', '在客户端启用微信登录'), + ('oauth.user.qq.enabled', 'false', 'boolean', '在客户端启用 QQ 登录'), + ('oauth.user.github.enabled', 'false', 'boolean', '在客户端启用 GitHub 登录'), + ('oauth.user.google.enabled', 'false', 'boolean', '在客户端启用 Google 登录') +ON DUPLICATE KEY UPDATE description = VALUES(description); diff --git a/im/.deploy/application-20260831-120612/backend/migrations/022_admin_sessions.sql b/im/.deploy/application-20260831-120612/backend/migrations/022_admin_sessions.sql new file mode 100644 index 0000000..0696e28 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/022_admin_sessions.sql @@ -0,0 +1,17 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS admin_sessions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + admin_user_id BIGINT UNSIGNED NOT NULL, + refresh_token_hash BINARY(32) NOT NULL, + user_agent_hash BINARY(32) NOT NULL, + ip VARCHAR(45) NOT NULL DEFAULT '', + expires_at DATETIME(3) NOT NULL, + last_active_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + revoked_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_admin_sessions_refresh (refresh_token_hash), + KEY idx_admin_sessions_user (admin_user_id, revoked_at, expires_at), + CONSTRAINT fk_admin_sessions_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/im/.deploy/application-20260831-120612/backend/migrations/023_admin_rbac.sql b/im/.deploy/application-20260831-120612/backend/migrations/023_admin_rbac.sql new file mode 100644 index 0000000..47da153 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/023_admin_rbac.sql @@ -0,0 +1,77 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS admin_roles ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + role_code VARCHAR(50) NOT NULL, + role_name VARCHAR(80) NOT NULL, + description VARCHAR(255) NOT NULL DEFAULT '', + built_in TINYINT(1) NOT NULL DEFAULT 0, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_admin_roles_code (role_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_permissions ( + permission_code VARCHAR(80) NOT NULL, + permission_name VARCHAR(100) NOT NULL, + permission_group VARCHAR(50) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + PRIMARY KEY (permission_code), + KEY idx_admin_permissions_group (permission_group, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_role_permissions ( + role_id BIGINT UNSIGNED NOT NULL, + permission_code VARCHAR(80) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (role_id, permission_code), + CONSTRAINT fk_admin_role_permissions_role FOREIGN KEY (role_id) REFERENCES admin_roles(id) ON DELETE CASCADE, + CONSTRAINT fk_admin_role_permissions_permission FOREIGN KEY (permission_code) REFERENCES admin_permissions(permission_code) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_user_roles ( + admin_user_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (admin_user_id, role_id), + KEY idx_admin_user_roles_role (role_id), + CONSTRAINT fk_admin_user_roles_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) ON DELETE CASCADE, + CONSTRAINT fk_admin_user_roles_role FOREIGN KEY (role_id) REFERENCES admin_roles(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO admin_permissions (permission_code, permission_name, permission_group, sort_order) VALUES + ('dashboard:view', '查看运营概览', 'dashboard', 10), + ('users:view', '查看用户', 'users', 20), + ('users:manage', '编辑用户资料和会员', 'users', 21), + ('users:security', '重置密码和强制下线', 'users', 22), + ('verification:manage', '审核用户认证', 'users', 23), + ('violations:manage', '处理违规和账号处罚', 'users', 24), + ('content:view', '查看动态内容', 'content', 30), + ('content:manage', '删除和处置动态', 'content', 31), + ('messages:view', '查看聊天消息记录', 'content', 32), + ('reports:handle', '处理举报', 'risk', 40), + ('risk:view', '查看风险信息', 'risk', 41), + ('membership:manage', '管理会员套餐', 'commerce', 50), + ('orders:view', '查看订单', 'commerce', 51), + ('orders:manage', '变更订单和退款', 'commerce', 52), + ('system:manage', '管理系统、集成和管理员', 'system', 60) +ON DUPLICATE KEY UPDATE + permission_name=VALUES(permission_name), + permission_group=VALUES(permission_group), + sort_order=VALUES(sort_order); + +INSERT INTO admin_roles (role_code, role_name, description, built_in, status) +VALUES ('super_admin', '超级管理员', '拥有平台全部权限;内置角色不可删除', 1, 1) +ON DUPLICATE KEY UPDATE role_name=VALUES(role_name), description=VALUES(description), built_in=1, status=1; + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_code) +SELECT r.id, p.permission_code +FROM admin_roles r CROSS JOIN admin_permissions p +WHERE r.role_code='super_admin'; + +-- Existing administrators keep their current capabilities after RBAC is enabled. +INSERT IGNORE INTO admin_user_roles (admin_user_id, role_id) +SELECT a.id, r.id +FROM admin_users a JOIN admin_roles r ON r.role_code='super_admin'; diff --git a/im/.deploy/application-20260831-120612/backend/migrations/024_user_verification_submission.sql b/im/.deploy/application-20260831-120612/backend/migrations/024_user_verification_submission.sql new file mode 100644 index 0000000..08a9e44 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/024_user_verification_submission.sql @@ -0,0 +1,11 @@ +SET NAMES utf8mb4; + +ALTER TABLE user_verifications + ADD COLUMN document_hash BINARY(32) NULL AFTER document_mask, + ADD COLUMN evidence_json TEXT NULL AFTER document_hash, + ADD KEY idx_user_verifications_document (document_hash); + +UPDATE user_verifications SET evidence_json='[]' WHERE evidence_json IS NULL OR evidence_json=''; + +ALTER TABLE user_verifications + MODIFY COLUMN evidence_json TEXT NOT NULL; diff --git a/im/.deploy/application-20260831-120612/backend/migrations/025_client_productization.sql b/im/.deploy/application-20260831-120612/backend/migrations/025_client_productization.sql new file mode 100644 index 0000000..183194f --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/025_client_productization.sql @@ -0,0 +1,140 @@ +SET NAMES utf8mb4; + +ALTER TABLE user_privacy_settings + ADD COLUMN invisible_visit TINYINT(1) NOT NULL DEFAULT 0 AFTER allow_profile_visit_record; + +ALTER TABLE membership_plans + ADD COLUMN daily_like_limit INT UNSIGNED NOT NULL DEFAULT 20 AFTER daily_active_chat_limit, + ADD COLUMN can_view_visitors TINYINT(1) NOT NULL DEFAULT 0 AFTER daily_like_limit, + ADD COLUMN can_invisible_visit TINYINT(1) NOT NULL DEFAULT 0 AFTER can_view_visitors, + ADD COLUMN recommendation_weight INT UNSIGNED NOT NULL DEFAULT 0 AFTER can_invisible_visit; + +UPDATE membership_plans +SET daily_like_limit=CASE WHEN level>=2 THEN 0 WHEN level=1 THEN 100 ELSE 20 END, + can_view_visitors=IF(level>=1,1,0), + can_invisible_visit=IF(level>=1,1,0), + recommendation_weight=CASE WHEN level>=2 THEN 30 WHEN level=1 THEN 15 ELSE 0 END; + +ALTER TABLE user_verifications + DROP PRIMARY KEY, + ADD PRIMARY KEY (user_id, verification_type); + +CREATE TABLE IF NOT EXISTS user_daily_like_usage ( + user_id BIGINT UNSIGNED NOT NULL, + usage_date DATE NOT NULL, + used_count INT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, usage_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_daily_like_targets ( + user_id BIGINT UNSIGNED NOT NULL, + target_type VARCHAR(20) NOT NULL, + target_id BIGINT UNSIGNED NOT NULL, + usage_date DATE NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, target_type, target_id, usage_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_notification_settings ( + user_id BIGINT UNSIGNED NOT NULL, + im_enabled TINYINT(1) NOT NULL DEFAULT 1, + interaction_enabled TINYINT(1) NOT NULL DEFAULT 1, + system_enabled TINYINT(1) NOT NULL DEFAULT 1, + sound_enabled TINYINT(1) NOT NULL DEFAULT 1, + vibration_enabled TINYINT(1) NOT NULL DEFAULT 1, + quiet_start VARCHAR(5) NOT NULL DEFAULT '', + quiet_end VARCHAR(5) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_push_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + device_id VARCHAR(100) NOT NULL, + provider VARCHAR(30) NOT NULL, + push_token VARCHAR(500) NOT NULL, + platform VARCHAR(20) NOT NULL DEFAULT '', + app_version VARCHAR(30) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + last_active_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_push_device_provider (user_id, device_id, provider), + KEY idx_push_user_status (user_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_feedback ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + category VARCHAR(30) NOT NULL, + content VARCHAR(2000) NOT NULL, + contact VARCHAR(100) NOT NULL DEFAULT '', + evidence_json JSON NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + reply_content VARCHAR(2000) NOT NULL DEFAULT '', + handled_by BIGINT UNSIGNED NULL, + handled_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_feedback_user_created (user_id, created_at), + KEY idx_feedback_status_created (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_account_closures ( + user_id BIGINT UNSIGNED NOT NULL, + reason VARCHAR(500) NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + requested_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + execute_after DATETIME(3) NOT NULL, + cancelled_at DATETIME(3) NULL, + completed_at DATETIME(3) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_closure_status_execute (status, execute_after) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_consents ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + agreement_type VARCHAR(30) NOT NULL, + agreement_version VARCHAR(30) NOT NULL, + accepted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + ip VARCHAR(45) NOT NULL DEFAULT '', + device_id VARCHAR(100) NOT NULL DEFAULT '', + PRIMARY KEY (id), + UNIQUE KEY uk_user_consent_version (user_id, agreement_type, agreement_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE orders + ADD COLUMN refund_reason VARCHAR(500) NOT NULL DEFAULT '' AFTER payment_notified_at, + ADD COLUMN refund_requested_at DATETIME(3) NULL AFTER refund_reason; + +INSERT INTO user_notification_settings(user_id) +SELECT id FROM users +ON DUPLICATE KEY UPDATE user_id=VALUES(user_id); + +INSERT INTO system_configs (config_key,config_value,value_type,description) VALUES + ('membership.free_daily_like_limit','20','number','普通用户每日点赞次数'), + ('account.cancellation_cooling_days','7','number','账号注销冷静期天数'), + ('legal.user_agreement_version','1.0','string','用户协议版本'), + ('legal.privacy_policy_version','1.0','string','隐私政策版本'), + ('legal.operator_name','','string','平台运营主体全称(上线前必填)'), + ('legal.contact','','string','隐私与客服联系方式(上线前必填)'), + ('legal.effective_date','','string','协议生效日期,格式 YYYY-MM-DD'), + ('legal.user_agreement_url','','string','完整用户协议 HTTPS 地址'), + ('legal.privacy_policy_url','','string','完整隐私政策 HTTPS 地址'), + ('app.maintenance.enabled','false','boolean','客户端维护模式'), + ('app.maintenance.message','系统维护中,请稍后再试','string','客户端维护提示'), + ('app.features.nearby','true','boolean','附近功能开关'), + ('app.features.feed','true','boolean','动态功能开关'), + ('app.features.membership','true','boolean','会员功能开关'), + ('app.features.im','true','boolean','即时通讯功能开关'), + ('app.min_version.android','1.0.0','string','Android 最低版本'), + ('app.min_version.ios','1.0.0','string','iOS 最低版本'), + ('app.min_version.h5','1.0.0','string','H5 最低版本') +ON DUPLICATE KEY UPDATE description=VALUES(description),value_type=VALUES(value_type); diff --git a/im/.deploy/application-20260831-120612/backend/migrations/026_report_operations.sql b/im/.deploy/application-20260831-120612/backend/migrations/026_report_operations.sql new file mode 100644 index 0000000..2a01644 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/026_report_operations.sql @@ -0,0 +1,17 @@ +ALTER TABLE reports + ADD COLUMN action_type VARCHAR(30) NOT NULL DEFAULT '' AFTER status, + ADD COLUMN handle_remark VARCHAR(1000) NOT NULL DEFAULT '' AFTER action_type; + +CREATE INDEX idx_reports_target ON reports(target_type, target_id, created_at); + +ALTER TABLE im_messages + ADD COLUMN admin_removed_at DATETIME(3) NULL AFTER recalled_at, + ADD COLUMN admin_removed_by BIGINT UNSIGNED NULL AFTER admin_removed_at, + ADD COLUMN admin_remove_reason VARCHAR(500) NOT NULL DEFAULT '' AFTER admin_removed_by; + +INSERT INTO admin_permissions(permission_code,permission_name,permission_group,sort_order) +VALUES('messages:manage','下架及恢复违规聊天消息','content',33) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),permission_group=VALUES(permission_group),sort_order=VALUES(sort_order); + +INSERT IGNORE INTO admin_role_permissions(role_id,permission_code) +SELECT id,'messages:manage' FROM admin_roles WHERE role_code='super_admin'; diff --git a/im/.deploy/application-20260831-120612/backend/migrations/027_app_oauth_login.sql b/im/.deploy/application-20260831-120612/backend/migrations/027_app_oauth_login.sql new file mode 100644 index 0000000..167ae2b --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/027_app_oauth_login.sql @@ -0,0 +1,31 @@ +SET NAMES utf8mb4; + +ALTER TABLE user_oauth_states + ADD COLUMN client_platform VARCHAR(10) NOT NULL DEFAULT 'h5', + ADD COLUMN app_proof_hash BINARY(32) NULL; + +ALTER TABLE user_oauth_login_codes + ADD COLUMN client_platform VARCHAR(10) NOT NULL DEFAULT 'h5', + ADD COLUMN app_proof_hash BINARY(32) NULL, + ADD COLUMN identity_scope VARCHAR(191) NOT NULL DEFAULT ''; + +-- OpenID is scoped to the provider's application. Keep existing H5 bindings, +-- and allow a user to bind the mobile application identity independently. +ALTER TABLE user_oauth_identities + ADD COLUMN identity_scope VARCHAR(191) NOT NULL DEFAULT '', + DROP INDEX uk_user_oauth_provider_subject, + DROP INDEX uk_user_oauth_user_provider, + ADD UNIQUE KEY uk_user_oauth_provider_subject (provider, identity_scope, subject), + ADD UNIQUE KEY uk_user_oauth_user_provider (user_id, provider, identity_scope); + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('oauth.app.frontend_callback_url', 'xingyuim://oauth/callback', 'string', 'App GitHub 授权结果地址,必须与打包的 URL Scheme 一致'), + ('oauth.app.wechat.enabled', 'false', 'boolean', '在 App 启用微信原生登录'), + ('oauth.app.qq.enabled', 'false', 'boolean', '在 App 启用 QQ 原生登录'), + ('oauth.app.github.enabled', 'false', 'boolean', '在 App 启用 GitHub 系统浏览器登录'), + ('oauth.app.google.enabled', 'false', 'boolean', '在 App 启用 Google 原生登录'), + ('oauth.app.wechat.client_id', '', 'string', '微信开放平台移动应用 AppID'), + ('oauth.app.wechat.client_secret', '', 'secret', '微信移动应用 AppSecret,仅服务端保存'), + ('oauth.app.qq.client_id', '', 'string', 'QQ 互联移动应用 AppID'), + ('oauth.app.google.client_ids', '', 'string', '允许的 Google Android/iOS OAuth Client ID,以英文逗号分隔') +ON DUPLICATE KEY UPDATE description = VALUES(description); diff --git a/im/.deploy/application-20260831-120612/backend/migrations/028_test_users.sql b/im/.deploy/application-20260831-120612/backend/migrations/028_test_users.sql new file mode 100644 index 0000000..e9e6321 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/028_test_users.sql @@ -0,0 +1,7 @@ +SET NAMES utf8mb4; + +-- Registration does not set these fields: genuine users remain non-test. +ALTER TABLE users + ADD COLUMN is_test TINYINT(1) NOT NULL DEFAULT 0, + ADD COLUMN test_batch VARCHAR(64) NOT NULL DEFAULT '', + ADD KEY idx_users_test_batch (is_test, test_batch, id); diff --git a/im/.deploy/application-20260831-120612/backend/migrations/029_admin_create_users.sql b/im/.deploy/application-20260831-120612/backend/migrations/029_admin_create_users.sql new file mode 100644 index 0000000..ab25065 --- /dev/null +++ b/im/.deploy/application-20260831-120612/backend/migrations/029_admin_create_users.sql @@ -0,0 +1,9 @@ +SET NAMES utf8mb4; + +INSERT INTO admin_permissions (permission_code, permission_name, permission_group, sort_order) +VALUES ('users:create', '新增客户端用户', 'users', 25) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), permission_group=VALUES(permission_group), sort_order=VALUES(sort_order); + +-- Grant to the built-in administrator role; other roles require an explicit grant. +INSERT IGNORE INTO admin_role_permissions (role_id, permission_code) +SELECT id, 'users:create' FROM admin_roles WHERE role_code='super_admin'; diff --git a/im/.deploy/application-20260831-120612/fixtures/test-users/README.md b/im/.deploy/application-20260831-120612/fixtures/test-users/README.md new file mode 100644 index 0000000..8cd1c9b --- /dev/null +++ b/im/.deploy/application-20260831-120612/fixtures/test-users/README.md @@ -0,0 +1,110 @@ +# 100 个明确标识的测试用户 + +## 数据范围 + +- 固定批次:`cn-adults-20260831-v1`。 +- 用户数量:100,男 50、女 50;公开编号 `TESTCN000001` 至 `TESTCN000100`。 +- 所有资料都带独立字段 `users.is_test=1`、`users.test_batch`。正式注册用户默认 `is_test=0`,注册和资料修改接口不能将其改成测试标识或清除标识。 +- 昵称带“测试·”前缀,简介明确说明虚构身份;管理端有数据类型标签、类型/批次筛选和详情展示。客户端推荐、附近、搜索/关系列表、主页显示“测试用户”标签。 +- 仅创建用户、资料、隐私设置。无真实手机号、无可用密码、无登录会话、无虚假定位/在线时间、无认证/会员/订单/动态/聊天/粉丝记录。这批是展示用资料,不是自动聊天机器人,也不提供测试登录密码。 +- 测试用户不计入后台注册、新增、活跃及资料完善人数;测试人数单独显示。其他已有互动/订单指标的原口径未改动。 +- 头像为 imagegen 内置工具生成的虚构中国成年人形象,男 5 张、女 5 张;每张由 10 条同性别资料复用,共 10 张不同头像,并非 100 名真人或 100 张不同头像。文件与生成提示词见 `avatars/`、`avatar-provenance.json`。 + +`users.json` 是已经生成的 100 条本地预览数据,头像 URL 使用 `http://127.0.0.1:8888/uploads`。它不是已导入数据库的凭证。CLI 导入会按传入的公开域名重新生成 URL。 + +## 发布顺序 + +先确认要导入的环境。不要同时对本地和线上执行,也不要打开 `IM_SEED_DEMO`。 + +1. 备份目标库,按现有迁移机制应用 `backend/migrations/028_test_users.sql`(保留迁移校验记录)。它只新增测试字段/索引,原用户默认正式用户。 +2. 发布包含本次字段的后端,再发布管理端和 H5;原生 App 需重新构建。不要让新后端连接未迁移的库,否则列表会因字段缺失报错。 +3. 确认后台文件存储设置的 `storage.local.directory` 实际值;未设置时使用 `IM_MEDIA_DIR`。头像必须拷贝到此目录,不能仅拷贝到一个任意的 uploads 目录。 +4. 先运行预览,检查数量/性别/域名;再显式执行导入命令。 +5. 验证 SQL 统计、管理端筛选和客户端标识;抽查每张头像地址都返回图片而不是 HTML/404。 + +头像采用项目已有的数字文件名规则,经 `/uploads/:name` 访问。`--public-base` 必须是本地媒体服务的真实公开 URL;即使普通用户上传切换到云存储,这批内置测试素材仍需配置可访问的本地媒体目录或事先将相同文件发布到指定公开 URL。 + +## 本地 PowerShell + +在 `backend` 目录执行,项目自带 Go 在 `..\.tools\go\bin\go.exe`。 + +```powershell +# 只输出预览 JSON,不连接或修改数据库。 +& '..\.tools\go\bin\go.exe' run ./cmd/seed-test-users --public-base http://127.0.0.1:8888/uploads + +# 先在本机环境变量设置 IM_DB_DSN;不要把密码提交到代码仓库。 +$env:IM_ENV = 'development' +& '..\.tools\go\bin\go.exe' run ./cmd/seed-test-users --apply --confirm-database im --public-base http://127.0.0.1:8888/uploads --avatars-dir ../fixtures/test-users/avatars --media-dir ./uploads +``` + +## 服务器 / 宝塔 + +先将 `fixtures/test-users/avatars` 和编译好的导入工具上传服务器。Go 项目运行环境的 `IM_DB_DSN` 必须是实际目标库;注意该项目曾使用 3307 端口,不要误连到面板默认 3306 的另一个数据库。 + +```sh +# 在 backend 目录构建 Linux 工具;也可在服务器原生 go build。 +GOOS=linux GOARCH=amd64 go build -o seed-test-users ./cmd/seed-test-users + +# 服务器上,加载已经存在且可信的后端环境文件,不回显内容。 +set -a +. /etc/xingyu-im-bt.env +set +a + +# 以下路径必须对应已上传的素材和实际本地存储配置。 +./seed-test-users --apply --allow-production-test-data \ + --confirm-database im \ + --public-base https://im.bchongw.com/uploads \ + --avatars-dir ./avatars \ + --media-dir /www/wwwroot/xingyu-data/uploads +``` + +如果目标库名字不是 `im`,请使用真实名称;程序会校验确认名称与当前连接的 `DATABASE()` 完全一致。生产环境导入还需要显式 `--allow-production-test-data`。启动普通后端不会自动生成这些测试账号。 + +## 幂等性与出错行为 + +整批数据在一个事务中写入,并持有批次互斥锁。成功应返回 `created=100, male=50, female=50`。再次执行应返回 `created=0, skipped=100`。 + +遇到现有正式用户编号冲突、批次不完整、测试用户已删除或性别被修改时,程序拒绝覆盖,事务回滚。请人工核查,不要清空用户表或删库重来。程序不会为了补足 100 人而覆盖现有数据。已复制而未引用的头像可能保留在媒体目录,重试会复用内容相同的文件;内容不同的同名文件不会被覆盖。 + +验证: + +```sql +SELECT u.test_batch, p.gender, COUNT(*) AS total +FROM users u JOIN user_profiles p ON p.user_id=u.id +WHERE u.is_test=1 AND u.test_batch='cn-adults-20260831-v1' AND u.deleted_at IS NULL +GROUP BY u.test_batch, p.gender; + +SELECT is_test, COUNT(*) FROM users WHERE deleted_at IS NULL GROUP BY is_test; +``` + +第一条应是 gender=1、gender=2 各 50。管理端“用户管理”选择“测试用户”,批次输入 `cn-adults-20260831-v1` 应显示 100 条;选择“正式注册用户”不应包含本批次。 + +## 测试 + +`go test ./...` 包含数据数量/性别/年龄/标签/URL、素材复制防覆盖和过滤参数测试。 + +设置 `IM_TEST_MYSQL_DSN` 为不带数据库名的本机 MySQL 连接后,`go test ./internal/testusers -v` 还会创建两个独立的 `im_fixture_test_<数字>` 临时库,验证真实 MySQL 的重复执行、原用户保留、冲突回滚。测试结束只删除自己创建的临时库,不连接应用数据库。测试不接受远程主机或带数据库名的 DSN。 + +## 已导入测试头像迁移至 COS + +2026-08-31 已将线上本批次的 10 张头像上传到 `gz-1349751149`,并把 100 条资料的头像链接更新为 COS 的持久 HTTPS URL;男女各 50 和测试标签不变。原图片和旧链接备份仍在服务器,详细结果见 `deploy/im.bchongw.com_部署记录.md`。 + +专用工具 `backend/cmd/migrate-test-avatars-cos` 只操作本批次,不改变系统存储配置,不删除本地图片、云端对象或其他用户。凭证从服务器环境及数据库加密配置读取,禁止通过命令行参数或日志传递密钥。它严格确认生产库 `127.0.0.1:3307/im` 和指定存储桶;默认只做读取预览。 + +在服务器使用已加载可信后端环境变量的 shell 中: + +```sh +# 只读核对,不上传或更新数据库。 +/www/server/xingyu-im/ops/migrate-test-avatars-cos \ + --confirm-database im --confirm-bucket gz-1349751149 \ + --media-dir /www/wwwroot/xingyu-data/uploads + +# 正式执行需额外添加 --apply,以及一个尚不存在的专用备份目录: +# --backup-dir /www/backup/xingyu-cos-avatars-YYYYMMDD-HHMMSS +``` + +每个 COS 文件必须匿名下载成功且内容与原 PNG 完全一致,才会在一个事务中替换数据库链接。已有同名对象内容不符、测试头像曾被编辑、部分资料缺失或上传验证失败时均拒绝覆盖。已迁移的同内容对象会复用,已相同的数据库链接不重复更新。 + +上传遵循现有对象权限。工具只允许对本次新上传、确需公开显示的测试头像设置对象级公有读作为回退;不会修改存储桶 ACL 或已有对象 ACL。本次迁移未触发该回退,权限改动数量为 0。相关接口行为见 [腾讯云 PUT Object](https://cloud.tencent.com/document/product/436/7749)。 + +离线测试:在 `backend` 运行 `go test ./cmd/migrate-test-avatars-cos -v`,测试使用内存 HTTP 服务,不访问腾讯云或生产数据库。 diff --git a/im/.deploy/application-20260831-120612/fixtures/test-users/avatar-provenance.json b/im/.deploy/application-20260831-120612/fixtures/test-users/avatar-provenance.json new file mode 100644 index 0000000..f5334c5 --- /dev/null +++ b/im/.deploy/application-20260831-120612/fixtures/test-users/avatar-provenance.json @@ -0,0 +1,68 @@ +{ + "generator": "built-in image_gen", + "date": "2026-08-31", + "uniquePortraits": 10, + "reuse": "每个性别5张头像,50条同性别测试资料循环使用;并非100名真人或100张不同头像。", + "assets": [ + { + "file": "90002811-1.png", + "gender": 1, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese man aged 27, short black hair, gentle smile, navy casual shirt, blurred green park background. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermarks, no logos, no collage." + }, + { + "file": "90002812-1.png", + "gender": 1, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese man aged 30, oval face, neatly parted short black hair, thin round glasses, light grey knit shirt, relaxed bookshop background. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermark, no logos, no collage." + }, + { + "file": "90002813-1.png", + "gender": 1, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese man aged 24, short textured black hair, broad friendly smile, teal T-shirt, softly blurred riverside walkway. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermark, no logos, no collage." + }, + { + "file": "90002814-1.png", + "gender": 1, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese man aged 33, short cropped black hair, light facial stubble, earth brown casual jacket, soft urban cafe background. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermark, no logos, no collage." + }, + { + "file": "90002815-1.png", + "gender": 1, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese man aged 28, longer swept black hair, clean shaven, white crewneck and blue overshirt, neutral blue-grey studio backdrop. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermark, no logos, no collage." + }, + { + "file": "90002821-1.png", + "gender": 2, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese woman aged 26, shoulder-length black hair, gentle smile, cream casual blouse, blurred green park background. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermarks, no logos, no collage." + }, + { + "file": "90002822-1.png", + "gender": 2, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese woman aged 29, long straight black hair, thin oval glasses, sage green blouse, softly blurred bookshop background. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermark, no logos, no collage." + }, + { + "file": "90002823-1.png", + "gender": 2, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese woman aged 24, short black bob with light bangs, cheerful smile, light blue denim shirt, softly blurred riverside walkway. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermark, no logos, no collage." + }, + { + "file": "90002824-1.png", + "gender": 2, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese woman aged 32, tied-back black hair, natural friendly expression, burgundy casual knit top, soft urban cafe background. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermark, no logos, no collage." + }, + { + "file": "90002825-1.png", + "gender": 2, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese woman aged 27, wavy shoulder-length dark hair, natural smile, pale blue blouse, neutral warm-grey studio backdrop. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermark, no logos, no collage." + } + ] +} diff --git a/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002811-1.png b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002811-1.png new file mode 100644 index 0000000..3a33179 Binary files /dev/null and b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002811-1.png differ diff --git a/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002812-1.png b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002812-1.png new file mode 100644 index 0000000..c6aaea4 Binary files /dev/null and b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002812-1.png differ diff --git a/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002813-1.png b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002813-1.png new file mode 100644 index 0000000..de330e3 Binary files /dev/null and b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002813-1.png differ diff --git a/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002814-1.png b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002814-1.png new file mode 100644 index 0000000..414bf56 Binary files /dev/null and b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002814-1.png differ diff --git a/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002815-1.png b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002815-1.png new file mode 100644 index 0000000..d588e8c Binary files /dev/null and b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002815-1.png differ diff --git a/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002821-1.png b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002821-1.png new file mode 100644 index 0000000..895db59 Binary files /dev/null and b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002821-1.png differ diff --git a/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002822-1.png b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002822-1.png new file mode 100644 index 0000000..f100b05 Binary files /dev/null and b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002822-1.png differ diff --git a/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002823-1.png b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002823-1.png new file mode 100644 index 0000000..cb10945 Binary files /dev/null and b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002823-1.png differ diff --git a/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002824-1.png b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002824-1.png new file mode 100644 index 0000000..ebe689b Binary files /dev/null and b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002824-1.png differ diff --git a/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002825-1.png b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002825-1.png new file mode 100644 index 0000000..3ca9013 Binary files /dev/null and b/im/.deploy/application-20260831-120612/fixtures/test-users/avatars/90002825-1.png differ diff --git a/im/.deploy/application-20260831-120612/fixtures/test-users/users.json b/im/.deploy/application-20260831-120612/fixtures/test-users/users.json new file mode 100644 index 0000000..593296d --- /dev/null +++ b/im/.deploy/application-20260831-120612/fixtures/test-users/users.json @@ -0,0 +1,1602 @@ +[ + { + "publicId": "TESTCN000001", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈沐川", + "gender": 1, + "birthday": "1999-01-01", + "height": 172, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000002", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈知远", + "gender": 1, + "birthday": "1996-02-02", + "height": 173, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000003", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈星河", + "gender": 1, + "birthday": "2002-03-03", + "height": 174, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000004", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈景行", + "gender": 1, + "birthday": "1993-04-04", + "height": 175, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000005", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈予安", + "gender": 1, + "birthday": "1998-05-05", + "height": 176, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000006", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林沐川", + "gender": 1, + "birthday": "1999-06-06", + "height": 177, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000007", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林知远", + "gender": 1, + "birthday": "1996-01-07", + "height": 178, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000008", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林星河", + "gender": 1, + "birthday": "2002-02-08", + "height": 179, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000009", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林景行", + "gender": 1, + "birthday": "1993-03-09", + "height": 180, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000010", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林予安", + "gender": 1, + "birthday": "1998-04-10", + "height": 181, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000011", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周沐川", + "gender": 1, + "birthday": "1999-05-11", + "height": 182, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000012", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周知远", + "gender": 1, + "birthday": "1996-06-12", + "height": 183, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000013", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周星河", + "gender": 1, + "birthday": "2002-01-13", + "height": 172, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000014", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周景行", + "gender": 1, + "birthday": "1993-02-14", + "height": 173, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000015", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周予安", + "gender": 1, + "birthday": "1998-03-15", + "height": 174, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000016", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许沐川", + "gender": 1, + "birthday": "1999-04-16", + "height": 175, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000017", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许知远", + "gender": 1, + "birthday": "1996-05-17", + "height": 176, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000018", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许星河", + "gender": 1, + "birthday": "2002-06-18", + "height": 177, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000019", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许景行", + "gender": 1, + "birthday": "1993-01-19", + "height": 178, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000020", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许予安", + "gender": 1, + "birthday": "1998-02-20", + "height": 179, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000021", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏沐川", + "gender": 1, + "birthday": "1999-03-21", + "height": 180, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000022", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏知远", + "gender": 1, + "birthday": "1996-04-22", + "height": 181, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000023", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏星河", + "gender": 1, + "birthday": "2002-05-23", + "height": 182, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000024", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏景行", + "gender": 1, + "birthday": "1993-06-24", + "height": 183, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000025", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏予安", + "gender": 1, + "birthday": "1998-01-25", + "height": 172, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000026", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈沐川", + "gender": 1, + "birthday": "1999-02-26", + "height": 173, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000027", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈知远", + "gender": 1, + "birthday": "1996-03-27", + "height": 174, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000028", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈星河", + "gender": 1, + "birthday": "2002-04-01", + "height": 175, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000029", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈景行", + "gender": 1, + "birthday": "1993-05-02", + "height": 176, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000030", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈予安", + "gender": 1, + "birthday": "1998-06-03", + "height": 177, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000031", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆沐川", + "gender": 1, + "birthday": "1999-01-04", + "height": 178, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000032", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆知远", + "gender": 1, + "birthday": "1996-02-05", + "height": 179, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000033", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆星河", + "gender": 1, + "birthday": "2002-03-06", + "height": 180, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000034", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆景行", + "gender": 1, + "birthday": "1993-04-07", + "height": 181, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000035", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆予安", + "gender": 1, + "birthday": "1998-05-08", + "height": 182, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000036", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾沐川", + "gender": 1, + "birthday": "1999-06-09", + "height": 183, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000037", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾知远", + "gender": 1, + "birthday": "1996-01-10", + "height": 172, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000038", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾星河", + "gender": 1, + "birthday": "2002-02-11", + "height": 173, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000039", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾景行", + "gender": 1, + "birthday": "1993-03-12", + "height": 174, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000040", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾予安", + "gender": 1, + "birthday": "1998-04-13", + "height": 175, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000041", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方沐川", + "gender": 1, + "birthday": "1999-05-14", + "height": 176, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000042", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方知远", + "gender": 1, + "birthday": "1996-06-15", + "height": 177, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000043", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方星河", + "gender": 1, + "birthday": "2002-01-16", + "height": 178, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000044", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方景行", + "gender": 1, + "birthday": "1993-02-17", + "height": 179, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000045", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方予安", + "gender": 1, + "birthday": "1998-03-18", + "height": 180, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000046", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季沐川", + "gender": 1, + "birthday": "1999-04-19", + "height": 181, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000047", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季知远", + "gender": 1, + "birthday": "1996-05-20", + "height": 182, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000048", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季星河", + "gender": 1, + "birthday": "2002-06-21", + "height": 183, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000049", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季景行", + "gender": 1, + "birthday": "1993-01-22", + "height": 172, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000050", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季予安", + "gender": 1, + "birthday": "1998-02-23", + "height": 173, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000051", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈晚晴", + "gender": 2, + "birthday": "2000-01-01", + "height": 160, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000052", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈知夏", + "gender": 2, + "birthday": "1997-02-02", + "height": 161, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000053", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈语桐", + "gender": 2, + "birthday": "2002-03-03", + "height": 162, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000054", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈清禾", + "gender": 2, + "birthday": "1994-04-04", + "height": 163, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000055", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈若宁", + "gender": 2, + "birthday": "1999-05-05", + "height": 164, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + }, + { + "publicId": "TESTCN000056", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林晚晴", + "gender": 2, + "birthday": "2000-06-06", + "height": 165, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000057", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林知夏", + "gender": 2, + "birthday": "1997-01-07", + "height": 166, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000058", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林语桐", + "gender": 2, + "birthday": "2002-02-08", + "height": 167, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000059", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林清禾", + "gender": 2, + "birthday": "1994-03-09", + "height": 168, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000060", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林若宁", + "gender": 2, + "birthday": "1999-04-10", + "height": 169, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + }, + { + "publicId": "TESTCN000061", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周晚晴", + "gender": 2, + "birthday": "2000-05-11", + "height": 170, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000062", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周知夏", + "gender": 2, + "birthday": "1997-06-12", + "height": 171, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000063", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周语桐", + "gender": 2, + "birthday": "2002-01-13", + "height": 160, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000064", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周清禾", + "gender": 2, + "birthday": "1994-02-14", + "height": 161, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000065", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周若宁", + "gender": 2, + "birthday": "1999-03-15", + "height": 162, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + }, + { + "publicId": "TESTCN000066", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许晚晴", + "gender": 2, + "birthday": "2000-04-16", + "height": 163, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000067", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许知夏", + "gender": 2, + "birthday": "1997-05-17", + "height": 164, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000068", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许语桐", + "gender": 2, + "birthday": "2002-06-18", + "height": 165, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000069", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许清禾", + "gender": 2, + "birthday": "1994-01-19", + "height": 166, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000070", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许若宁", + "gender": 2, + "birthday": "1999-02-20", + "height": 167, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + }, + { + "publicId": "TESTCN000071", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏晚晴", + "gender": 2, + "birthday": "2000-03-21", + "height": 168, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000072", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏知夏", + "gender": 2, + "birthday": "1997-04-22", + "height": 169, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000073", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏语桐", + "gender": 2, + "birthday": "2002-05-23", + "height": 170, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000074", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏清禾", + "gender": 2, + "birthday": "1994-06-24", + "height": 171, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000075", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏若宁", + "gender": 2, + "birthday": "1999-01-25", + "height": 160, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + }, + { + "publicId": "TESTCN000076", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈晚晴", + "gender": 2, + "birthday": "2000-02-26", + "height": 161, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000077", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈知夏", + "gender": 2, + "birthday": "1997-03-27", + "height": 162, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000078", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈语桐", + "gender": 2, + "birthday": "2002-04-01", + "height": 163, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000079", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈清禾", + "gender": 2, + "birthday": "1994-05-02", + "height": 164, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000080", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈若宁", + "gender": 2, + "birthday": "1999-06-03", + "height": 165, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + }, + { + "publicId": "TESTCN000081", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆晚晴", + "gender": 2, + "birthday": "2000-01-04", + "height": 166, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000082", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆知夏", + "gender": 2, + "birthday": "1997-02-05", + "height": 167, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000083", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆语桐", + "gender": 2, + "birthday": "2002-03-06", + "height": 168, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000084", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆清禾", + "gender": 2, + "birthday": "1994-04-07", + "height": 169, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000085", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆若宁", + "gender": 2, + "birthday": "1999-05-08", + "height": 170, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + }, + { + "publicId": "TESTCN000086", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾晚晴", + "gender": 2, + "birthday": "2000-06-09", + "height": 171, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000087", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾知夏", + "gender": 2, + "birthday": "1997-01-10", + "height": 160, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000088", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾语桐", + "gender": 2, + "birthday": "2002-02-11", + "height": 161, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000089", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾清禾", + "gender": 2, + "birthday": "1994-03-12", + "height": 162, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000090", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾若宁", + "gender": 2, + "birthday": "1999-04-13", + "height": 163, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + }, + { + "publicId": "TESTCN000091", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方晚晴", + "gender": 2, + "birthday": "2000-05-14", + "height": 164, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000092", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方知夏", + "gender": 2, + "birthday": "1997-06-15", + "height": 165, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000093", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方语桐", + "gender": 2, + "birthday": "2002-01-16", + "height": 166, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000094", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方清禾", + "gender": 2, + "birthday": "1994-02-17", + "height": 167, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000095", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方若宁", + "gender": 2, + "birthday": "1999-03-18", + "height": 168, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + }, + { + "publicId": "TESTCN000096", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季晚晴", + "gender": 2, + "birthday": "2000-04-19", + "height": 169, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000097", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季知夏", + "gender": 2, + "birthday": "1997-05-20", + "height": 170, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000098", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季语桐", + "gender": 2, + "birthday": "2002-06-21", + "height": 171, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000099", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季清禾", + "gender": 2, + "birthday": "1994-01-22", + "height": 160, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000100", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季若宁", + "gender": 2, + "birthday": "1999-02-23", + "height": 161, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + } +] diff --git a/im/.deploy/application-20260831-120612/live-verification.json b/im/.deploy/application-20260831-120612/live-verification.json new file mode 100644 index 0000000..d6f8899 --- /dev/null +++ b/im/.deploy/application-20260831-120612/live-verification.json @@ -0,0 +1,40 @@ +{ + "release": { + "stamp": "20260831-121140", + "archiveSha256": "865952392f733cefcdd9ad257fd701a55be57c434a55f381a7045a5b035d0c17", + "stage": "/www/server/xingyu-im/releases/application-20260831-121140", + "previousBackendSha256": "92b306000ab787e2eb20e69daf25cc3374d082a88f7301e24811a2b7d0022d9d", + "rollbackBackendSha256": "c041e6549b06e32314e131cabcdc5bd2035ae08f494feb5cb3f5b8b332d1035b", + "previousAdmin": "/www/wwwroot/xingyu-admin/releases/test-users-20260831-113046", + "previousH5": "/www/wwwroot/xingyu-h5/releases/test-users-20260831-113046", + "before": { + "configValuesSha256": "ad1db20d91818827ec0e1c402e236ff7f52eb63de767d657454fd36caf80b465", + "testProfilesSha256": "1122f382e2debd8ee2f91eec66f56660cb31085cd565c8444a6c05a4ab041789", + "testUsers": 100 + }, + "backup": "/www/backup/xingyu-application-20260831-121140", + "backendSha256": "54e699a73fd6d3cec7baae3b1224a03cca6f2ddfdcb91c23f05e1fa258514b56", + "admin": "/www/wwwroot/xingyu-admin/releases/application-20260831-121140", + "h5": "/www/wwwroot/xingyu-h5/releases/application-20260831-121140", + "migrationsApplied": [ + "027_app_oauth_login.sql", + "029_admin_create_users.sql" + ], + "publicChecks": true, + "preservedExistingDataAndConfig": true + }, + "runningPids": [ + 733469 + ], + "migrationsRecorded": 29, + "recordCounts": { + "users": 100, + "admin_users": 1, + "user_sessions": 2, + "orders": 0, + "posts": 0 + }, + "testCosAvatars": 100, + "superAdminCreateUserPermission": true, + "backupProtected": true +} \ No newline at end of file diff --git a/im/.deploy/application-20260831-120612/payload/admin.zip b/im/.deploy/application-20260831-120612/payload/admin.zip new file mode 100644 index 0000000..2994129 Binary files /dev/null and b/im/.deploy/application-20260831-120612/payload/admin.zip differ diff --git a/im/.deploy/application-20260831-120612/payload/backend-source.zip b/im/.deploy/application-20260831-120612/payload/backend-source.zip new file mode 100644 index 0000000..6a9a576 Binary files /dev/null and b/im/.deploy/application-20260831-120612/payload/backend-source.zip differ diff --git a/im/.deploy/application-20260831-120612/payload/baota_publish_application.py b/im/.deploy/application-20260831-120612/payload/baota_publish_application.py new file mode 100644 index 0000000..00e3fcc --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/baota_publish_application.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Publish this reviewed application release on the existing BaoTa host. + +Uses the installed, previously verified transport/filesystem helpers. Never +seeds data, changes credentials, enables OAuth channels, or restores a live DB. +""" +import fcntl +import gzip +import hashlib +import json +import os +import pwd +import re +import shutil +import subprocess +import sys +import time +import urllib.error +from pathlib import Path + +sys.path.insert(0, '/www/server/xingyu-im/ops') +from baota_publish_test_users import BT, WEB, database, digest, env_values, extract, fetch, health, query, restart, switch + +EXPECTED_OLD = '92b306000ab787e2eb20e69daf25cc3374d082a88f7301e24811a2b7d0022d9d' +MIGRATIONS = ['027_app_oauth_login.sql', '029_admin_create_users.sql'] + + +def marker(conn): + # Fingerprints only; configuration values and personal records never leave + # this process or appear in deployment logs. + configs = query(conn, "SELECT config_key,config_value FROM system_configs WHERE config_key NOT LIKE 'oauth.app.%%' ORDER BY config_key") + profiles = query(conn, "SELECT u.id,u.public_id,u.is_test,u.test_batch,p.avatar_url FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.is_test=1 ORDER BY u.id") + return { + 'configValuesSha256': hashlib.sha256(repr(configs).encode()).hexdigest(), + 'testProfilesSha256': hashlib.sha256(repr(profiles).encode()).hexdigest(), + 'testUsers': len(profiles), + } + + +def prepare_static(stage, kind, stamp): + entry = WEB / ('admin' if kind == 'admin' else 'app') + releases = Path('/www/wwwroot/xingyu-' + kind + '/releases') + assert entry.is_symlink() + previous = entry.resolve() + assert previous.parent == releases + release = releases / ('application-' + stamp) + extract(stage / (kind + '.zip'), release) + index = (release / 'index.html').read_text() + assert ('/admin/jse/' if kind == 'admin' else '/app/assets/') in index + if kind == 'admin': + assert '"/admin/v1"' in (release / '_app.config.js').read_text() + # Preserve resources used by tabs opened before the atomic entry switch. + for folder in (['js', 'jse', 'css'] if kind == 'admin' else ['assets']): + for source in (previous / folder).rglob('*'): + if source.is_file(): + destination = release / source.relative_to(previous) + if not destination.exists(): + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(source), str(destination)) + www = pwd.getpwnam('www') + for parent, directories, files in os.walk(str(release)): + os.chown(parent, www.pw_uid, www.pw_gid) + os.chmod(parent, 0o755) + for name in files: + path = os.path.join(parent, name) + os.chown(path, www.pw_uid, www.pw_gid) + os.chmod(path, 0o644) + return entry, previous, release + + +def replace_binary(source, suffix): + target = BT / ('xingyu-api.' + suffix) + assert not target.exists() + shutil.copy2(str(source), str(target)) + www = pwd.getpwnam('www') + os.chown(str(target), www.pw_uid, www.pw_gid) + os.chmod(str(target), 0o750) + os.replace(str(target), str(BT / 'xingyu-api')) + + +def public_checks(admin, h5): + for prefix, release in [('/admin/', admin), ('/app/', h5)]: + status, headers, body = fetch(prefix) + assert status == 200 and body == (release / 'index.html').read_bytes() + assert 'no-store' in ','.join(headers.get_all('Cache-Control', [])) + paths = re.findall(r'(?:src|href)="(/(?:admin|app)/[^"?#]+)', body.decode()) + for path in paths: + if path.endswith(('.js', '.css')): + assert fetch(path)[0] == 200 + assert fetch('/admin/_app.config.js')[2] == (admin / '_app.config.js').read_bytes() + for path in ['/admin/v1/users', '/admin/v1/auth/codes', '/ws']: + try: + fetch(path) + except urllib.error.HTTPError as error: + assert error.code == 401 + else: + raise RuntimeError('private endpoint became public') + for platform in ['app', 'h5']: + status, _, body = fetch('/api/v1/auth/oauth/providers?platform=' + platform) + assert status == 200 and json.loads(body)['code'] == 0 + health() + + +def publish(archive, expected): + assert os.geteuid() == 0 + archive = Path(archive).resolve() + assert archive.parent == Path('/tmp') and re.fullmatch('[a-f0-9]{64}', expected) + assert digest(archive) == expected + lock = open('/www/server/xingyu-im/ops/application-release.lock', 'a') + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + values = env_values() + conn, db_options = database(values) + assert digest(BT / 'xingyu-api') == EXPECTED_OLD, 'production changed; re-audit required' + health() + subprocess.run(['/www/server/nginx/sbin/nginx', '-t'], check=True) + stamp = time.strftime('%Y%m%d-%H%M%S') + stage = Path('/www/server/xingyu-im/releases/application-' + stamp) + extract(archive, stage) + os.chmod(str(stage), 0o700) + manifest = json.loads((stage / 'manifest.json').read_text()) + for name, sha in manifest['files'].items(): + path = (stage / name).resolve() + assert stage in path.parents and path.is_file() and digest(path) == sha + recorded = dict(query(conn, 'SELECT version,checksum FROM schema_migrations')) + for name, checksum in recorded.items(): + assert (stage / 'migrations' / name).is_file() and digest(stage / 'migrations' / name) == checksum, 'migration history mismatch: ' + name + pending = sorted(file.name for file in (stage / 'migrations').glob('*.sql') if file.name not in recorded) + assert pending == MIGRATIONS, 'unreviewed pending migrations' + before = marker(conn) + state = { + 'stamp': stamp, 'archiveSha256': expected, 'stage': str(stage), + 'previousBackendSha256': EXPECTED_OLD, + 'rollbackBackendSha256': manifest['files']['xingyu-api-rollback'], + 'previousAdmin': str((WEB / 'admin').resolve()), 'previousH5': str((WEB / 'app').resolve()), + 'before': before, + } + backup = Path('/www/backup/xingyu-application-' + stamp) + backup.mkdir(mode=0o700) + state['backup'] = str(backup) + dump = subprocess.Popen(['/opt/mysql-8.4.11/bin/mysqldump', '-h', '127.0.0.1', '-P', '3307', '-u', db_options['user'], '--single-transaction', '--no-tablespaces', '--set-gtid-purged=OFF', '--routines', '--triggers', 'im'], env=dict(os.environ, MYSQL_PWD=db_options['password']), stdout=subprocess.PIPE, stderr=subprocess.PIPE) + with gzip.open(str(backup / 'im.sql.gz'), 'wb') as output: + shutil.copyfileobj(dump.stdout, output) + _, errors = dump.communicate() + if dump.returncode: + raise RuntimeError('database backup failed') + os.chmod(str(backup / 'im.sql.gz'), 0o600) + with gzip.open(str(backup / 'im.sql.gz'), 'rb') as check: + assert b'MySQL dump' in check.read(4096) + while check.read(1024 * 1024): + pass + shutil.copy2(str(BT / 'xingyu-api'), str(backup / 'xingyu-api-original')) + shutil.copy2(str(stage / 'xingyu-api-rollback'), str(backup / 'xingyu-api-compatible-rollback')) + shutil.copy2('/etc/xingyu-im-bt.env', str(backup / 'environment.env')) + os.chmod(str(backup / 'environment.env'), 0o600) + (backup / 'state.json').write_text(json.dumps(state, indent=2)) + print('BACKUP_VERIFIED=' + str(backup), flush=True) + admin = prepare_static(stage, 'admin', stamp) + h5 = prepare_static(stage, 'h5', stamp) + switched = [] + binary_changed = False + try: + for name in pending: + file = stage / 'migrations' / name + sql = '\n'.join(line for line in file.read_text().splitlines() if not line.lstrip().startswith('--')) + for statement in sql.split(';'): + if statement.strip(): + query(conn, statement) + query(conn, 'INSERT INTO schema_migrations(version,checksum) VALUES(%s,%s)', (name, digest(file))) + shutil.copy2(str(file), str(BT / 'migrations' / name)) + print('MIGRATION_APPLIED=' + name, flush=True) + assert marker(conn) == before, 'existing data or config changed during migrations' + assert query(conn, "SELECT COUNT(*) FROM admin_role_permissions rp JOIN admin_roles r ON r.id=rp.role_id WHERE r.role_code='super_admin' AND rp.permission_code='users:create'")[0][0] == 1 + replace_binary(stage / 'xingyu-api', 'next-' + stamp) + binary_changed = True + print('BACKEND_RESTARTING', flush=True) + restart() + print('BACKEND_HEALTHY', flush=True) + for entry, previous, release in [admin, h5]: + switch(entry, release, stamp) + switched.append((entry, previous)) + public_checks(admin[2], h5[2]) + assert marker(conn) == before, 'existing config or test profiles changed during release' + assert digest(BT / 'xingyu-api') == manifest['files']['xingyu-api'] + state.update({'backendSha256': digest(BT / 'xingyu-api'), 'admin': str(admin[2]), 'h5': str(h5[2]), 'migrationsApplied': pending, 'publicChecks': True, 'preservedExistingDataAndConfig': True}) + (backup / 'result.json').write_text(json.dumps(state, indent=2)) + print('PUBLISHED=' + json.dumps(state), flush=True) + except Exception: + for entry, previous in reversed(switched): + switch(entry, previous, stamp + '-rollback') + if binary_changed: + replace_binary(backup / 'xingyu-api-compatible-rollback', 'rollback-' + stamp) + restart() + print('APPLICATION_ROLLED_BACK; added schema retained; backup=' + str(backup), flush=True) + raise + finally: + conn.close() + + +if __name__ == '__main__': + publish(sys.argv[1], sys.argv[2]) diff --git a/im/.deploy/application-20260831-120612/payload/h5.zip b/im/.deploy/application-20260831-120612/payload/h5.zip new file mode 100644 index 0000000..f3982a4 Binary files /dev/null and b/im/.deploy/application-20260831-120612/payload/h5.zip differ diff --git a/im/.deploy/application-20260831-120612/payload/manifest.json b/im/.deploy/application-20260831-120612/payload/manifest.json new file mode 100644 index 0000000..4b2fd8a --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/manifest.json @@ -0,0 +1,40 @@ +{ + "version": "application-20260831-120612", + "files": { + "admin.zip": "b6788109550128021eff4c27ccd858156fd7c2e7316b23b2944f26e8f4abf3d6", + "backend-source.zip": "e1f148ac01b047fc6679e7ff2d4d9fbdc8639bb82a42a473a41c387a23b289c5", + "baota_publish_application.py": "4b98bc9f4d42d239326828b7a93aec89f1b9921472686476f1631a1126650ad3", + "h5.zip": "0c0d783a30e08fc9d1189ce857b66dbb55eae268069838e8be04a62759aae644", + "migrations/001_users.sql": "0f9bea5d15c2f97c5d6e1d5f8a809a3918dd5b570c062bd4c9373516eec05199", + "migrations/002_social.sql": "9f388f5c27ebb9953623f73e3e71512a1ecd27fab46cda6bd48a9c3d424eacd7", + "migrations/003_feed.sql": "af93d32f85b4e2aadbb0982329e280694866443208f8dff460b7c791274c4cd3", + "migrations/004_im.sql": "e746de400fa28aeea628d868db10b15a057bd889c3ee7da7b10ec717cbb2ab25", + "migrations/005_membership.sql": "168b68be9c5fcc49dec9eca486a94c951fe927da5367acfc78de0c17ac5258de", + "migrations/006_moderation.sql": "9b5f18a50107bba66b11fe7071360cc8304306bd3a7b63ceaf655cf50996ede1", + "migrations/007_system.sql": "7ca38046c4340a0b90fa6b3a9841de6c000f7900f752549430906e9b242085fc", + "migrations/008_integrations.sql": "60b1eab5c521d7ae816525737c5b082f1da43dfe1ed11c563562e2b0dc462eab", + "migrations/009_repair_utf8_defaults.sql": "730c251df14220d9f7e9939c82a94e256054b84a59dac7349ef838af94130bbe", + "migrations/010_admin_operations.sql": "56734b696f70f552755172848d5101eb047ea4d56b070faf6c39002725ee713d", + "migrations/011_commerce_message_admin.sql": "43428915647d094c93388bdb00bff6c3deaf3a551dac4c6e47bcf14e564b3c3b", + "migrations/012_expand_client_message_id.sql": "7b88cde91da5cc96bc920ddb15f7ab76c1de0fbf00545c6788dcd014eeac4a41", + "migrations/013_security_hardening.sql": "1fef2b53ba87cccc9c046527291b6d93a2e257a43c83587bc1dc319809868282", + "migrations/014_payment_gateway.sql": "e105a0fcc939be709c8fc2cabbb94aa5180eb4ad13b8c6726f22dbd023fa66e3", + "migrations/015_payment_refunds.sql": "1f478603af271c54b481569aa935d17c09ca970dcb4f30e6fc22a5e89f47b99a", + "migrations/016_admin_password_security.sql": "bec121ea7f7ad8b7518bb2ad14cfa25ee21e263afcf37bf116f02d8c40422216", + "migrations/017_sms_cloud_providers.sql": "6a7a36d77478135f6a880d796689c7e012ad7298d774ae90fcff45fcc9129168", + "migrations/018_object_storage_providers.sql": "b36a8017cc52ad219fc2b5667b4012d9c0126cdd7b005ba75405cc8ebff19e0a", + "migrations/019_daily_active_chat_limit.sql": "6aef0a315535ec3b8ac45b74bd5a4efa21b83089b79431d1d3f6dc99b80421f9", + "migrations/020_admin_oauth_login.sql": "bdeacabc3827b724cf4f03ae9958522ce0fadab556e0e44cf5410d9573140aac", + "migrations/021_user_oauth_login.sql": "27ebd7e041a83ed03d24d6052541ad507c6e44d52f8f12f073bb040c63f3c752", + "migrations/022_admin_sessions.sql": "11608b0a05ad7d7531ec8c3a9dd12ee8837588f655552e7799c08e6266561259", + "migrations/023_admin_rbac.sql": "c06e2cb9bdba05e3dc4ac297fc5dd6991f3a6292c57005bee1bb0bd8f223f995", + "migrations/024_user_verification_submission.sql": "f324ed8a92065de0c8415923b5a74109f416174dbc80fd92452871501a995117", + "migrations/025_client_productization.sql": "ba0fa237a46bafcbda3ec8716931dcf4c1c37aadc295606b5f2c8a1bb6182e52", + "migrations/026_report_operations.sql": "7c7eb9605facbcf47ab716c6c70f070dc266692a7ffb301b7901d551f19723f8", + "migrations/027_app_oauth_login.sql": "2726f3734fa8ab60cdc6cb028fc8b793e69597503b05c705651bcbaa8992de67", + "migrations/028_test_users.sql": "bd140838bc02290dd93eea298e344f0aa8cbb31f277b8c8fe4f073fbcfa603b1", + "migrations/029_admin_create_users.sql": "9bd7f9e1bd1f04dff4e0f10579bdb2569f7e048d33d79447554b91a5e02f212b", + "xingyu-api": "54e699a73fd6d3cec7baae3b1224a03cca6f2ddfdcb91c23f05e1fa258514b56", + "xingyu-api-rollback": "c041e6549b06e32314e131cabcdc5bd2035ae08f494feb5cb3f5b8b332d1035b" + } +} \ No newline at end of file diff --git a/im/.deploy/application-20260831-120612/payload/migrations/001_users.sql b/im/.deploy/application-20260831-120612/payload/migrations/001_users.sql new file mode 100644 index 0000000..03659f5 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/001_users.sql @@ -0,0 +1,123 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + public_id VARCHAR(20) NOT NULL, + country_code VARCHAR(8) NOT NULL DEFAULT '+86', + phone_hash BINARY(32) NULL, + phone_cipher VARBINARY(255) NULL, + password_hash VARCHAR(255) NOT NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + risk_level TINYINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_users_public_id (public_id), + UNIQUE KEY uk_users_phone_hash (phone_hash), + KEY idx_users_status_created (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_profiles ( + user_id BIGINT UNSIGNED NOT NULL, + nickname VARCHAR(50) NOT NULL, + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + cover_url VARCHAR(500) NOT NULL DEFAULT '', + gender TINYINT UNSIGNED NOT NULL DEFAULT 0, + birthday DATE NULL, + height_cm SMALLINT UNSIGNED NULL, + city_code VARCHAR(20) NOT NULL DEFAULT '', + city_name VARCHAR(50) NOT NULL DEFAULT '', + occupation VARCHAR(100) NOT NULL DEFAULT '', + education TINYINT UNSIGNED NOT NULL DEFAULT 0, + relationship_status TINYINT UNSIGNED NOT NULL DEFAULT 0, + bio VARCHAR(500) NOT NULL DEFAULT '', + profile_score SMALLINT UNSIGNED NOT NULL DEFAULT 0, + is_vip TINYINT UNSIGNED NOT NULL DEFAULT 0, + vip_level TINYINT UNSIGNED NOT NULL DEFAULT 0, + last_active_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_profiles_city_active (city_code, last_active_at), + CONSTRAINT fk_profiles_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_privacy_settings ( + user_id BIGINT UNSIGNED NOT NULL, + nearby_visible TINYINT(1) NOT NULL DEFAULT 1, + distance_visible TINYINT(1) NOT NULL DEFAULT 1, + online_visible TINYINT(1) NOT NULL DEFAULT 1, + last_active_visible TINYINT(1) NOT NULL DEFAULT 1, + allow_stranger_message TINYINT(1) NOT NULL DEFAULT 1, + allow_profile_visit_record TINYINT(1) NOT NULL DEFAULT 1, + allow_search TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + CONSTRAINT fk_privacy_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_devices ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + device_id VARCHAR(100) NOT NULL, + platform VARCHAR(20) NOT NULL, + device_model VARCHAR(100) NOT NULL DEFAULT '', + os_version VARCHAR(50) NOT NULL DEFAULT '', + app_version VARCHAR(30) NOT NULL DEFAULT '', + push_provider VARCHAR(30) NOT NULL DEFAULT '', + push_token VARCHAR(255) NOT NULL DEFAULT '', + last_ip VARCHAR(45) NOT NULL DEFAULT '', + last_active_at DATETIME(3) NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_device_user_device (user_id, device_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_sessions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + device_id VARCHAR(100) NOT NULL, + refresh_token_hash BINARY(32) NOT NULL, + expires_at DATETIME(3) NOT NULL, + last_active_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + revoked_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sessions_user (user_id, revoked_at), + UNIQUE KEY uk_sessions_refresh (refresh_token_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tags ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + category VARCHAR(30) NOT NULL, + name VARCHAR(50) NOT NULL, + icon VARCHAR(100) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_tags_category_name (category, name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_tags ( + user_id BIGINT UNSIGNED NOT NULL, + tag_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, tag_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + username VARCHAR(50) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + real_name VARCHAR(50) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + last_login_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_admin_username (username) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/application-20260831-120612/payload/migrations/002_social.sql b/im/.deploy/application-20260831-120612/payload/migrations/002_social.sql new file mode 100644 index 0000000..1944c57 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/002_social.sql @@ -0,0 +1,65 @@ +CREATE TABLE IF NOT EXISTS user_follows ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + target_user_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_follow_pair (user_id, target_user_id), + KEY idx_follow_target (target_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_likes ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + target_user_id BIGINT UNSIGNED NOT NULL, + source VARCHAR(30) NOT NULL DEFAULT 'discover', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_like_pair (user_id, target_user_id), + KEY idx_like_target (target_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_matches ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user1_id BIGINT UNSIGNED NOT NULL, + user2_id BIGINT UNSIGNED NOT NULL, + matched_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + PRIMARY KEY (id), + UNIQUE KEY uk_match_pair (user1_id, user2_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_blocks ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + blocked_user_id BIGINT UNSIGNED NOT NULL, + reason VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_block_pair (user_id, blocked_user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS profile_visits ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + viewer_user_id BIGINT UNSIGNED NOT NULL, + target_user_id BIGINT UNSIGNED NOT NULL, + source VARCHAR(30) NOT NULL DEFAULT 'profile', + visited_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_visit_target_time (target_user_id, visited_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_location_states ( + user_id BIGINT UNSIGNED NOT NULL, + city_code VARCHAR(20) NOT NULL, + location_cell VARCHAR(32) NOT NULL DEFAULT '', + latitude DECIMAL(10,7) NULL, + longitude DECIMAL(10,7) NULL, + last_location_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + source VARCHAR(20) NOT NULL DEFAULT 'gps', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_location_city_time (city_code, last_location_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/application-20260831-120612/payload/migrations/003_feed.sql b/im/.deploy/application-20260831-120612/payload/migrations/003_feed.sql new file mode 100644 index 0000000..28a9be2 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/003_feed.sql @@ -0,0 +1,72 @@ +CREATE TABLE IF NOT EXISTS media_assets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + owner_user_id BIGINT UNSIGNED NOT NULL, + media_type VARCHAR(20) NOT NULL, + storage_provider VARCHAR(20) NOT NULL DEFAULT 'local', + bucket VARCHAR(100) NOT NULL DEFAULT '', + object_key VARCHAR(500) NOT NULL DEFAULT '', + public_url VARCHAR(500) NOT NULL DEFAULT '', + mime_type VARCHAR(100) NOT NULL DEFAULT '', + file_size BIGINT UNSIGNED NOT NULL DEFAULT 0, + width INT UNSIGNED NULL, + height INT UNSIGNED NULL, + duration_ms INT UNSIGNED NULL, + moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 1, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS posts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + content VARCHAR(2000) NOT NULL DEFAULT '', + visibility TINYINT UNSIGNED NOT NULL DEFAULT 1, + city_code VARCHAR(20) NOT NULL DEFAULT '', + location_text VARCHAR(100) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 1, + like_count INT UNSIGNED NOT NULL DEFAULT 0, + comment_count INT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id), + KEY idx_posts_status_created (status, created_at), + KEY idx_posts_user_created (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS post_media ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + post_id BIGINT UNSIGNED NOT NULL, + media_id BIGINT UNSIGNED NULL, + media_url VARCHAR(500) NOT NULL DEFAULT '', + media_type VARCHAR(20) NOT NULL DEFAULT 'image', + sort_order INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY idx_post_media_post (post_id, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS post_likes ( + post_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (post_id, user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS post_comments ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + post_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + parent_comment_id BIGINT UNSIGNED NULL, + reply_user_id BIGINT UNSIGNED NULL, + content VARCHAR(1000) NOT NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id), + KEY idx_comments_post_created (post_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/application-20260831-120612/payload/migrations/004_im.sql b/im/.deploy/application-20260831-120612/payload/migrations/004_im.sql new file mode 100644 index 0000000..63b090c --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/004_im.sql @@ -0,0 +1,68 @@ +CREATE TABLE IF NOT EXISTS im_conversations ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + conversation_type TINYINT UNSIGNED NOT NULL DEFAULT 1, + last_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + last_message_id BIGINT UNSIGNED NULL, + last_message_at DATETIME(3) NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_conversation_last (last_message_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_direct_conversations ( + conversation_id BIGINT UNSIGNED NOT NULL, + user1_id BIGINT UNSIGNED NOT NULL, + user2_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (conversation_id), + UNIQUE KEY uk_direct_pair (user1_id, user2_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_conversation_members ( + conversation_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + join_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + read_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + delivered_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + clear_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + pinned TINYINT(1) NOT NULL DEFAULT 0, + muted TINYINT(1) NOT NULL DEFAULT 0, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + joined_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (conversation_id, user_id), + KEY idx_member_user (user_id, updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_messages ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + conversation_id BIGINT UNSIGNED NOT NULL, + seq BIGINT UNSIGNED NOT NULL, + sender_id BIGINT UNSIGNED NOT NULL, + client_msg_id CHAR(26) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + message_type SMALLINT UNSIGNED NOT NULL, + body MEDIUMBLOB NOT NULL, + reply_to_message_id BIGINT UNSIGNED NULL, + moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 1, + recalled_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_conv_seq (conversation_id, seq), + UNIQUE KEY uk_sender_client_msg (sender_id, client_msg_id), + KEY idx_conv_created (conversation_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_user_sync_events ( + user_id BIGINT UNSIGNED NOT NULL, + event_seq BIGINT UNSIGNED NOT NULL, + event_type SMALLINT UNSIGNED NOT NULL, + conversation_id BIGINT UNSIGNED NOT NULL, + message_seq BIGINT UNSIGNED NOT NULL, + event_data MEDIUMBLOB NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, event_seq), + KEY idx_sync_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/application-20260831-120612/payload/migrations/005_membership.sql b/im/.deploy/application-20260831-120612/payload/migrations/005_membership.sql new file mode 100644 index 0000000..ed854a3 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/005_membership.sql @@ -0,0 +1,80 @@ +CREATE TABLE IF NOT EXISTS membership_plans ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + code VARCHAR(30) NOT NULL, + name VARCHAR(50) NOT NULL, + level TINYINT UNSIGNED NOT NULL, + duration_days INT UNSIGNED NOT NULL, + price_cent INT UNSIGNED NOT NULL, + original_price_cent INT UNSIGNED NOT NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_plan_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS benefit_definitions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + benefit_key VARCHAR(50) NOT NULL, + name VARCHAR(100) NOT NULL, + value_type VARCHAR(20) NOT NULL, + description VARCHAR(255) NOT NULL DEFAULT '', + PRIMARY KEY (id), + UNIQUE KEY uk_benefit_key (benefit_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS plan_benefits ( + plan_id BIGINT UNSIGNED NOT NULL, + benefit_id BIGINT UNSIGNED NOT NULL, + benefit_value VARCHAR(255) NOT NULL, + PRIMARY KEY (plan_id, benefit_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS subscriptions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + plan_id BIGINT UNSIGNED NOT NULL, + source VARCHAR(30) NOT NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + started_at DATETIME(3) NOT NULL, + expires_at DATETIME(3) NOT NULL, + auto_renew TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sub_user_expire (user_id, expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_entitlements ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + benefit_key VARCHAR(50) NOT NULL, + benefit_value VARCHAR(255) NOT NULL, + source_type VARCHAR(30) NOT NULL, + source_id BIGINT UNSIGNED NOT NULL, + started_at DATETIME(3) NOT NULL, + expires_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_entitlement_user_key (user_id, benefit_key, expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS orders ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_no VARCHAR(40) NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + product_type VARCHAR(30) NOT NULL, + product_id BIGINT UNSIGNED NOT NULL, + amount_cent INT UNSIGNED NOT NULL, + currency CHAR(3) NOT NULL DEFAULT 'CNY', + status VARCHAR(20) NOT NULL DEFAULT 'CREATED', + channel VARCHAR(30) NOT NULL DEFAULT '', + paid_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_order_no (order_no), + KEY idx_order_user_created (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/application-20260831-120612/payload/migrations/006_moderation.sql b/im/.deploy/application-20260831-120612/payload/migrations/006_moderation.sql new file mode 100644 index 0000000..887eec5 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/006_moderation.sql @@ -0,0 +1,72 @@ +CREATE TABLE IF NOT EXISTS reports ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + reporter_user_id BIGINT UNSIGNED NOT NULL, + target_type VARCHAR(30) NOT NULL, + target_id BIGINT UNSIGNED NOT NULL, + reason_code VARCHAR(50) NOT NULL, + description VARCHAR(1000) NOT NULL DEFAULT '', + evidence_json JSON NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + handled_by BIGINT UNSIGNED NULL, + handled_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_reports_status_created (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS moderation_tasks ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + target_type VARCHAR(30) NOT NULL, + target_id BIGINT UNSIGNED NOT NULL, + content_type VARCHAR(30) NOT NULL, + risk_score INT NOT NULL DEFAULT 0, + machine_result JSON NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + reviewer_id BIGINT UNSIGNED NULL, + review_result VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + reviewed_at DATETIME(3) NULL, + PRIMARY KEY (id), + KEY idx_moderation_status_created (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_risk_profiles ( + user_id BIGINT UNSIGNED NOT NULL, + risk_score INT NOT NULL DEFAULT 0, + risk_level TINYINT UNSIGNED NOT NULL DEFAULT 0, + message_score INT NOT NULL DEFAULT 0, + device_score INT NOT NULL DEFAULT 0, + report_score INT NOT NULL DEFAULT 0, + behavior_score INT NOT NULL DEFAULT 0, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_risk_level_score (risk_level, risk_score) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS risk_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + event_type VARCHAR(50) NOT NULL, + score_delta INT NOT NULL, + device_id VARCHAR(100) NOT NULL DEFAULT '', + ip VARCHAR(45) NOT NULL DEFAULT '', + metadata JSON NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_risk_event_user_created (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS notifications ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + type VARCHAR(30) NOT NULL, + title VARCHAR(100) NOT NULL, + content VARCHAR(1000) NOT NULL, + biz_type VARCHAR(30) NOT NULL DEFAULT '', + biz_id BIGINT UNSIGNED NULL, + read_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_notify_user_read_created (user_id, read_at, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/application-20260831-120612/payload/migrations/007_system.sql b/im/.deploy/application-20260831-120612/payload/migrations/007_system.sql new file mode 100644 index 0000000..850e6f0 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/007_system.sql @@ -0,0 +1,70 @@ +CREATE TABLE IF NOT EXISTS system_configs ( + config_key VARCHAR(100) NOT NULL, + config_value TEXT NOT NULL, + value_type VARCHAR(20) NOT NULL DEFAULT 'string', + description VARCHAR(255) NOT NULL DEFAULT '', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (config_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS banners ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + title VARCHAR(100) NOT NULL, + image_url VARCHAR(500) NOT NULL, + link_url VARCHAR(500) NOT NULL DEFAULT '', + position VARCHAR(30) NOT NULL DEFAULT 'home', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + starts_at DATETIME(3) NULL, + ends_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS app_versions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + platform VARCHAR(20) NOT NULL, + version VARCHAR(30) NOT NULL, + build_number INT UNSIGNED NOT NULL, + force_update TINYINT(1) NOT NULL DEFAULT 0, + download_url VARCHAR(500) NOT NULL DEFAULT '', + release_notes VARCHAR(2000) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_version_platform_build (platform, build_number) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_audit_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + admin_user_id BIGINT UNSIGNED NOT NULL, + action VARCHAR(100) NOT NULL, + target_type VARCHAR(50) NOT NULL DEFAULT '', + target_id BIGINT UNSIGNED NULL, + request_data JSON NULL, + ip VARCHAR(45) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_audit_admin_created (admin_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('im.recall_seconds', '120', 'number', '消息撤回时间窗口'), + ('nearby.max_distance_km', '50', 'number', '附近的人最大距离'), + ('stranger.daily_limit', '10', 'number', '普通用户每日主动聊天人数') +ON DUPLICATE KEY UPDATE description = VALUES(description); + +INSERT INTO membership_plans (code, name, level, duration_days, price_cent, original_price_cent, status, sort_order) VALUES + ('VIP_1M', 'VIP 1个月', 1, 30, 2800, 4000, 1, 10), + ('VIP_3M', 'VIP 3个月', 1, 90, 6800, 9000, 1, 20), + ('SVIP_12M', 'SVIP 12个月', 2, 365, 22800, 36000, 1, 30) +ON DUPLICATE KEY UPDATE name = VALUES(name), price_cent = VALUES(price_cent), original_price_cent = VALUES(original_price_cent); + +INSERT INTO tags (category, name, icon, status, sort_order) VALUES + ('personality', '天秤座', '', 1, 10), + ('hobby', '摄影爱好者', '', 1, 20), + ('hobby', '旅行达人', '', 1, 30), + ('hobby', '电影', '', 1, 40), + ('hobby', '音乐', '', 1, 50) +ON DUPLICATE KEY UPDATE status = VALUES(status); + diff --git a/im/.deploy/application-20260831-120612/payload/migrations/008_integrations.sql b/im/.deploy/application-20260831-120612/payload/migrations/008_integrations.sql new file mode 100644 index 0000000..e1d902e --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/008_integrations.sql @@ -0,0 +1,37 @@ +CREATE TABLE IF NOT EXISTS sms_verification_codes ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + phone_hash BINARY(32) NOT NULL, + scene VARCHAR(30) NOT NULL, + code_hash BINARY(32) NOT NULL, + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sms_phone_scene_created (phone_hash, scene, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('sms.enabled', 'true', 'boolean', '是否启用短信服务'), + ('sms.provider', 'debug', 'string', '短信提供商:debug 或 webhook'), + ('sms.sign_name', '星遇社交', 'string', '短信签名'), + ('sms.template_register', 'REGISTER', 'string', '注册验证码模板 ID'), + ('sms.template_login', 'LOGIN', 'string', '登录验证码模板 ID'), + ('sms.template_reset', 'RESET', 'string', '找回密码模板 ID'), + ('sms.webhook_url', '', 'string', '短信网关 Webhook 地址'), + ('sms.webhook_token', '', 'secret', '短信网关鉴权令牌'), + ('sms.debug_code', '123456', 'secret', '本地调试验证码'), + ('sms.expire_seconds', '300', 'number', '验证码有效期(秒)'), + ('payment.mode', 'sandbox', 'string', '支付模式:sandbox 或 live'), + ('payment.alipay.enabled', 'true', 'boolean', '是否启用支付宝'), + ('payment.alipay.app_id', '', 'string', '支付宝应用 APPID'), + ('payment.alipay.private_key', '', 'secret', '支付宝应用私钥'), + ('payment.alipay.public_key', '', 'secret', '支付宝公钥'), + ('payment.alipay.notify_url', '', 'string', '支付宝异步通知地址'), + ('payment.wechat.enabled', 'true', 'boolean', '是否启用微信支付'), + ('payment.wechat.app_id', '', 'string', '微信支付 AppID'), + ('payment.wechat.mch_id', '', 'string', '微信支付商户号'), + ('payment.wechat.api_v3_key', '', 'secret', '微信支付 APIv3 密钥'), + ('payment.wechat.private_key', '', 'secret', '微信支付商户私钥'), + ('payment.wechat.serial_no', '', 'string', '微信支付证书序列号'), + ('payment.wechat.notify_url', '', 'string', '微信支付回调地址') +ON DUPLICATE KEY UPDATE description = VALUES(description), value_type = VALUES(value_type); diff --git a/im/.deploy/application-20260831-120612/payload/migrations/009_repair_utf8_defaults.sql b/im/.deploy/application-20260831-120612/payload/migrations/009_repair_utf8_defaults.sql new file mode 100644 index 0000000..d40b76f --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/009_repair_utf8_defaults.sql @@ -0,0 +1,6 @@ +-- PowerShell 5 may encode text piped to native executables as the active ANSI +-- code page. Repair the only user-visible non-ASCII integration default for +-- databases initialized by the earlier migration runner. +UPDATE system_configs +SET config_value = CONVERT(0xE6989FE98187E7A4BEE4BAA4 USING utf8mb4) +WHERE config_key = 'sms.sign_name' AND config_value = '????'; diff --git a/im/.deploy/application-20260831-120612/payload/migrations/010_admin_operations.sql b/im/.deploy/application-20260831-120612/payload/migrations/010_admin_operations.sql new file mode 100644 index 0000000..4fea041 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/010_admin_operations.sql @@ -0,0 +1,64 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS user_verifications ( + user_id BIGINT UNSIGNED NOT NULL, + verification_type VARCHAR(30) NOT NULL DEFAULT 'real_name', + status VARCHAR(20) NOT NULL DEFAULT 'UNVERIFIED', + real_name VARCHAR(50) NOT NULL DEFAULT '', + document_mask VARCHAR(80) NOT NULL DEFAULT '', + remark VARCHAR(500) NOT NULL DEFAULT '', + reviewer_admin_id BIGINT UNSIGNED NULL, + submitted_at DATETIME(3) NULL, + reviewed_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_verification_status_updated (status, updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_sanctions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + sanction_type VARCHAR(30) NOT NULL, + reason VARCHAR(500) NOT NULL, + starts_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + expires_at DATETIME(3) NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + operator_admin_id BIGINT UNSIGNED NOT NULL, + revoked_by BIGINT UNSIGNED NULL, + revoked_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sanction_user_status_expire (user_id, status, expires_at), + KEY idx_sanction_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_security_controls ( + user_id BIGINT UNSIGNED NOT NULL, + token_version INT UNSIGNED NOT NULL DEFAULT 0, + force_logout_at DATETIME(3) NULL, + password_reset_at DATETIME(3) NULL, + last_operator_admin_id BIGINT UNSIGNED NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +SET @token_version_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_security_controls' AND COLUMN_NAME='token_version'); +SET @token_version_sql=IF(@token_version_exists=0,'ALTER TABLE user_security_controls ADD COLUMN token_version INT UNSIGNED NOT NULL DEFAULT 0 AFTER user_id','SELECT 1'); +PREPARE token_version_stmt FROM @token_version_sql; +EXECUTE token_version_stmt; +DEALLOCATE PREPARE token_version_stmt; + +INSERT INTO user_verifications (user_id, status) +SELECT u.id, 'UNVERIFIED' FROM users u +LEFT JOIN user_verifications v ON v.user_id=u.id +WHERE v.user_id IS NULL; + +INSERT INTO subscriptions (user_id, plan_id, source, status, started_at, expires_at) +SELECT p.user_id, mp.id, 'legacy_backfill', 1, NOW(3), DATE_ADD(NOW(3), INTERVAL mp.duration_days DAY) +FROM user_profiles p +JOIN membership_plans mp ON mp.level=p.vip_level + AND mp.duration_days=(SELECT MAX(mp2.duration_days) FROM membership_plans mp2 WHERE mp2.level=p.vip_level) +WHERE p.is_vip=1 AND p.vip_level>0 + AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.user_id=p.user_id AND s.status=1 AND s.expires_at>NOW(3)); diff --git a/im/.deploy/application-20260831-120612/payload/migrations/011_commerce_message_admin.sql b/im/.deploy/application-20260831-120612/payload/migrations/011_commerce_message_admin.sql new file mode 100644 index 0000000..8d7fef8 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/011_commerce_message_admin.sql @@ -0,0 +1,25 @@ +SET NAMES utf8mb4; + +SET @plan_deleted_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='membership_plans' AND COLUMN_NAME='deleted_at'); +SET @plan_deleted_sql=IF(@plan_deleted_exists=0,'ALTER TABLE membership_plans ADD COLUMN deleted_at DATETIME(3) NULL AFTER updated_at','SELECT 1'); +PREPARE plan_deleted_stmt FROM @plan_deleted_sql; +EXECUTE plan_deleted_stmt; +DEALLOCATE PREPARE plan_deleted_stmt; + +SET @order_deleted_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='deleted_at'); +SET @order_deleted_sql=IF(@order_deleted_exists=0,'ALTER TABLE orders ADD COLUMN deleted_at DATETIME(3) NULL AFTER updated_at','SELECT 1'); +PREPARE order_deleted_stmt FROM @order_deleted_sql; +EXECUTE order_deleted_stmt; +DEALLOCATE PREPARE order_deleted_stmt; + +SET @plan_deleted_index_exists=(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='membership_plans' AND INDEX_NAME='idx_plan_deleted_sort'); +SET @plan_deleted_index_sql=IF(@plan_deleted_index_exists=0,'ALTER TABLE membership_plans ADD KEY idx_plan_deleted_sort (deleted_at,sort_order)','SELECT 1'); +PREPARE plan_deleted_index_stmt FROM @plan_deleted_index_sql; +EXECUTE plan_deleted_index_stmt; +DEALLOCATE PREPARE plan_deleted_index_stmt; + +SET @order_deleted_index_exists=(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND INDEX_NAME='idx_order_deleted_created'); +SET @order_deleted_index_sql=IF(@order_deleted_index_exists=0,'ALTER TABLE orders ADD KEY idx_order_deleted_created (deleted_at,created_at)','SELECT 1'); +PREPARE order_deleted_index_stmt FROM @order_deleted_index_sql; +EXECUTE order_deleted_index_stmt; +DEALLOCATE PREPARE order_deleted_index_stmt; diff --git a/im/.deploy/application-20260831-120612/payload/migrations/012_expand_client_message_id.sql b/im/.deploy/application-20260831-120612/payload/migrations/012_expand_client_message_id.sql new file mode 100644 index 0000000..8010cc5 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/012_expand_client_message_id.sql @@ -0,0 +1,6 @@ +SET NAMES utf8mb4; + +-- Accept both the canonical 26-character client ID and UUID-style IDs from +-- older/cached clients while preserving sender-level idempotency. +ALTER TABLE im_messages + MODIFY COLUMN client_msg_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL; diff --git a/im/.deploy/application-20260831-120612/payload/migrations/013_security_hardening.sql b/im/.deploy/application-20260831-120612/payload/migrations/013_security_hardening.sql new file mode 100644 index 0000000..543c563 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/013_security_hardening.sql @@ -0,0 +1,12 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS api_rate_limits ( + bucket_key BINARY(32) NOT NULL, + action_name VARCHAR(40) NOT NULL, + hits INT UNSIGNED NOT NULL DEFAULT 1, + expires_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (bucket_key), + KEY idx_rate_limit_expires (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/im/.deploy/application-20260831-120612/payload/migrations/014_payment_gateway.sql b/im/.deploy/application-20260831-120612/payload/migrations/014_payment_gateway.sql new file mode 100644 index 0000000..6a21487 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/014_payment_gateway.sql @@ -0,0 +1,61 @@ +SET NAMES utf8mb4; + +SET @provider_order_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='provider_order_no'); +SET @provider_order_sql=IF(@provider_order_exists=0,'ALTER TABLE orders ADD COLUMN provider_order_no VARCHAR(100) NOT NULL DEFAULT '''' AFTER channel','SELECT 1'); +PREPARE provider_order_stmt FROM @provider_order_sql; +EXECUTE provider_order_stmt; +DEALLOCATE PREPARE provider_order_stmt; + +SET @checkout_url_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='checkout_url'); +SET @checkout_url_sql=IF(@checkout_url_exists=0,'ALTER TABLE orders ADD COLUMN checkout_url VARCHAR(1000) NOT NULL DEFAULT '''' AFTER provider_order_no','SELECT 1'); +PREPARE checkout_url_stmt FROM @checkout_url_sql; +EXECUTE checkout_url_stmt; +DEALLOCATE PREPARE checkout_url_stmt; + +SET @payment_payload_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='payment_payload'); +SET @payment_payload_sql=IF(@payment_payload_exists=0,'ALTER TABLE orders ADD COLUMN payment_payload MEDIUMTEXT NULL AFTER checkout_url','SELECT 1'); +PREPARE payment_payload_stmt FROM @payment_payload_sql; +EXECUTE payment_payload_stmt; +DEALLOCATE PREPARE payment_payload_stmt; + +SET @paid_amount_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='paid_amount_cent'); +SET @paid_amount_sql=IF(@paid_amount_exists=0,'ALTER TABLE orders ADD COLUMN paid_amount_cent INT UNSIGNED NULL AFTER amount_cent','SELECT 1'); +PREPARE paid_amount_stmt FROM @paid_amount_sql; +EXECUTE paid_amount_stmt; +DEALLOCATE PREPARE paid_amount_stmt; + +SET @payment_notified_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='payment_notified_at'); +SET @payment_notified_sql=IF(@payment_notified_exists=0,'ALTER TABLE orders ADD COLUMN payment_notified_at DATETIME(3) NULL AFTER paid_at','SELECT 1'); +PREPARE payment_notified_stmt FROM @payment_notified_sql; +EXECUTE payment_notified_stmt; +DEALLOCATE PREPARE payment_notified_stmt; + +SET @provider_order_index_exists=(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND INDEX_NAME='idx_order_provider_no'); +SET @provider_order_index_sql=IF(@provider_order_index_exists=0,'ALTER TABLE orders ADD KEY idx_order_provider_no (provider_order_no)','SELECT 1'); +PREPARE provider_order_index_stmt FROM @provider_order_index_sql; +EXECUTE provider_order_index_stmt; +DEALLOCATE PREPARE provider_order_index_stmt; + +CREATE TABLE IF NOT EXISTS payment_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + event_id VARCHAR(100) NOT NULL, + order_no VARCHAR(40) NOT NULL, + channel VARCHAR(30) NOT NULL, + provider_order_no VARCHAR(100) NOT NULL DEFAULT '', + event_status VARCHAR(30) NOT NULL, + amount_cent INT UNSIGNED NOT NULL DEFAULT 0, + raw_payload MEDIUMTEXT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_payment_event (event_id), + KEY idx_payment_event_order (order_no,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key,config_value,value_type,description) VALUES + ('payment.gateway.create_url','','string','统一支付网关创建支付地址'), + ('payment.gateway.token','','secret','统一支付网关 Bearer Token'), + ('payment.gateway.notify_secret','','secret','支付通知 HMAC-SHA256 密钥'), + ('payment.gateway.notify_url','','string','本系统支付通知公网 HTTPS 地址'), + ('payment.gateway.return_url','','string','支付完成后的客户端返回地址'), + ('payment.gateway.timeout_seconds','10','number','支付网关请求超时秒数') +ON DUPLICATE KEY UPDATE description=VALUES(description),value_type=VALUES(value_type); diff --git a/im/.deploy/application-20260831-120612/payload/migrations/015_payment_refunds.sql b/im/.deploy/application-20260831-120612/payload/migrations/015_payment_refunds.sql new file mode 100644 index 0000000..d5bfc53 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/015_payment_refunds.sql @@ -0,0 +1,5 @@ +SET NAMES utf8mb4; + +INSERT INTO system_configs (config_key,config_value,value_type,description) VALUES + ('payment.gateway.refund_url','','string','统一支付网关退款申请地址') +ON DUPLICATE KEY UPDATE description=VALUES(description),value_type=VALUES(value_type); diff --git a/im/.deploy/application-20260831-120612/payload/migrations/016_admin_password_security.sql b/im/.deploy/application-20260831-120612/payload/migrations/016_admin_password_security.sql new file mode 100644 index 0000000..0f0ffc7 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/016_admin_password_security.sql @@ -0,0 +1,6 @@ +SET NAMES utf8mb4; + +ALTER TABLE admin_users + ADD COLUMN token_version INT UNSIGNED NOT NULL DEFAULT 0 AFTER status, + ADD COLUMN password_changed_at DATETIME(3) NULL AFTER last_login_at; + diff --git a/im/.deploy/application-20260831-120612/payload/migrations/017_sms_cloud_providers.sql b/im/.deploy/application-20260831-120612/payload/migrations/017_sms_cloud_providers.sql new file mode 100644 index 0000000..cb1a9d9 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/017_sms_cloud_providers.sql @@ -0,0 +1,39 @@ +SET NAMES utf8mb4; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('sms.aliyun.endpoint', 'https://dysmsapi.aliyuncs.com', 'string', '阿里云短信 API 地址'), + ('sms.aliyun.access_key_id', '', 'secret', '阿里云 AccessKey ID'), + ('sms.aliyun.access_key_secret', '', 'secret', '阿里云 AccessKey Secret'), + ('sms.aliyun.sign_name', '', 'string', '阿里云短信签名'), + ('sms.aliyun.template_register', '', 'string', '阿里云注册模板 Code'), + ('sms.aliyun.template_login', '', 'string', '阿里云登录模板 Code'), + ('sms.aliyun.template_reset', '', 'string', '阿里云重置密码模板 Code'), + ('sms.aliyun.template_params', '{"code":"{{code}}"}', 'string', '阿里云模板变量 JSON'), + + ('sms.tencent.endpoint', 'https://sms.tencentcloudapi.com', 'string', '腾讯云短信 API 地址'), + ('sms.tencent.secret_id', '', 'secret', '腾讯云 SecretId'), + ('sms.tencent.secret_key', '', 'secret', '腾讯云 SecretKey'), + ('sms.tencent.sdk_app_id', '', 'string', '腾讯云短信 SdkAppId'), + ('sms.tencent.region', 'ap-guangzhou', 'string', '腾讯云短信地域'), + ('sms.tencent.sign_name', '', 'string', '腾讯云短信签名'), + ('sms.tencent.template_register', '', 'string', '腾讯云注册模板 ID'), + ('sms.tencent.template_login', '', 'string', '腾讯云登录模板 ID'), + ('sms.tencent.template_reset', '', 'string', '腾讯云重置密码模板 ID'), + ('sms.tencent.template_params', '["{{code}}"]', 'string', '腾讯云模板参数 JSON'), + + ('sms.huawei.endpoint', '', 'string', '华为云短信 APP 接入地址'), + ('sms.huawei.app_key', '', 'secret', '华为云短信 Application Key'), + ('sms.huawei.app_secret', '', 'secret', '华为云短信 Application Secret'), + ('sms.huawei.sender', '', 'string', '华为云短信签名通道号'), + ('sms.huawei.signature', '', 'string', '华为云短信签名名称'), + ('sms.huawei.template_register', '', 'string', '华为云注册模板 ID'), + ('sms.huawei.template_login', '', 'string', '华为云登录模板 ID'), + ('sms.huawei.template_reset', '', 'string', '华为云重置密码模板 ID'), + ('sms.huawei.template_params', '["{{code}}"]', 'string', '华为云模板参数 JSON'), + ('sms.huawei.status_callback', '', 'string', '华为云短信状态回调地址') +ON DUPLICATE KEY UPDATE description = VALUES(description), value_type = VALUES(value_type); + +UPDATE system_configs +SET description = '短信提供商:aliyun、tencent、huawei、webhook 或 debug' +WHERE config_key = 'sms.provider'; + diff --git a/im/.deploy/application-20260831-120612/payload/migrations/018_object_storage_providers.sql b/im/.deploy/application-20260831-120612/payload/migrations/018_object_storage_providers.sql new file mode 100644 index 0000000..9174ca5 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/018_object_storage_providers.sql @@ -0,0 +1,39 @@ +SET NAMES utf8mb4; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('storage.provider', 'local', 'string', '文件存储提供商:local、aliyun_oss、tencent_cos、qiniu、huawei_obs 或 huawei_flexus'), + ('storage.object_prefix', 'media', 'string', '云端对象键前缀'), + + ('storage.local.directory', './uploads', 'string', '本地文件存储目录'), + ('storage.local.public_base_url', '', 'string', '本地文件公开访问地址'), + + ('storage.aliyun_oss.endpoint', 'https://oss-cn-hangzhou.aliyuncs.com', 'string', '阿里云 OSS Endpoint'), + ('storage.aliyun_oss.region', 'cn-hangzhou', 'string', '阿里云 OSS Region'), + ('storage.aliyun_oss.bucket', '', 'string', '阿里云 OSS Bucket'), + ('storage.aliyun_oss.access_key_id', '', 'secret', '阿里云 OSS AccessKey ID'), + ('storage.aliyun_oss.access_key_secret', '', 'secret', '阿里云 OSS AccessKey Secret'), + ('storage.aliyun_oss.public_base_url', '', 'string', '阿里云 OSS 文件访问域名'), + + ('storage.tencent_cos.endpoint', '', 'string', '腾讯云 COS Bucket URL'), + ('storage.tencent_cos.bucket', '', 'string', '腾讯云 COS Bucket'), + ('storage.tencent_cos.secret_id', '', 'secret', '腾讯云 COS SecretId'), + ('storage.tencent_cos.secret_key', '', 'secret', '腾讯云 COS SecretKey'), + ('storage.tencent_cos.public_base_url', '', 'string', '腾讯云 COS 文件访问域名'), + + ('storage.qiniu.bucket', '', 'string', '七牛云 Kodo 空间名称'), + ('storage.qiniu.access_key', '', 'secret', '七牛云 AccessKey'), + ('storage.qiniu.secret_key', '', 'secret', '七牛云 SecretKey'), + ('storage.qiniu.public_base_url', '', 'string', '七牛云文件访问域名'), + + ('storage.huawei_obs.endpoint', 'https://obs.cn-north-4.myhuaweicloud.com', 'string', '华为云 OBS Endpoint'), + ('storage.huawei_obs.bucket', '', 'string', '华为云 OBS Bucket'), + ('storage.huawei_obs.access_key', '', 'secret', '华为云 OBS Access Key'), + ('storage.huawei_obs.secret_key', '', 'secret', '华为云 OBS Secret Key'), + ('storage.huawei_obs.public_base_url', '', 'string', '华为云 OBS 文件访问域名'), + + ('storage.huawei_flexus.endpoint', '', 'string', '华为云 Flexus 对象存储 Endpoint'), + ('storage.huawei_flexus.bucket', '', 'string', '华为云 Flexus 对象存储 Bucket'), + ('storage.huawei_flexus.access_key', '', 'secret', '华为云 Flexus 对象存储 Access Key'), + ('storage.huawei_flexus.secret_key', '', 'secret', '华为云 Flexus 对象存储 Secret Key'), + ('storage.huawei_flexus.public_base_url', '', 'string', '华为云 Flexus 对象存储文件访问域名') +ON DUPLICATE KEY UPDATE description = VALUES(description), value_type = VALUES(value_type); diff --git a/im/.deploy/application-20260831-120612/payload/migrations/019_daily_active_chat_limit.sql b/im/.deploy/application-20260831-120612/payload/migrations/019_daily_active_chat_limit.sql new file mode 100644 index 0000000..24efbb6 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/019_daily_active_chat_limit.sql @@ -0,0 +1,34 @@ +SET @daily_chat_limit_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='membership_plans' AND COLUMN_NAME='daily_active_chat_limit'); +SET @daily_chat_limit_sql=IF(@daily_chat_limit_exists=0,'ALTER TABLE membership_plans ADD COLUMN daily_active_chat_limit INT UNSIGNED NOT NULL DEFAULT 20 AFTER duration_days','SELECT 1'); +PREPARE daily_chat_limit_stmt FROM @daily_chat_limit_sql; +EXECUTE daily_chat_limit_stmt; +DEALLOCATE PREPARE daily_chat_limit_stmt; + +UPDATE membership_plans +SET daily_active_chat_limit=CASE WHEN level>=2 THEN 100 ELSE 20 END +WHERE daily_active_chat_limit=20; + +INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES + ('membership.free_daily_active_chat_limit','5','integer','普通用户每日可主动聊天的不同用户数,0 表示不限制') +ON DUPLICATE KEY UPDATE description=VALUES(description); + +CREATE TABLE IF NOT EXISTS im_daily_active_chat_usage ( + user_id BIGINT UNSIGNED NOT NULL, + usage_date DATE NOT NULL, + used_count INT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id,usage_date), + KEY idx_daily_chat_usage_date (usage_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_daily_active_chat_targets ( + user_id BIGINT UNSIGNED NOT NULL, + target_user_id BIGINT UNSIGNED NOT NULL, + usage_date DATE NOT NULL, + conversation_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id,target_user_id,usage_date), + KEY idx_daily_chat_target_date (target_user_id,usage_date), + KEY idx_daily_chat_conversation (conversation_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/im/.deploy/application-20260831-120612/payload/migrations/020_admin_oauth_login.sql b/im/.deploy/application-20260831-120612/payload/migrations/020_admin_oauth_login.sql new file mode 100644 index 0000000..e93527f --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/020_admin_oauth_login.sql @@ -0,0 +1,87 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS admin_oauth_identities ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + provider VARCHAR(20) NOT NULL, + subject VARCHAR(191) NOT NULL, + admin_user_id BIGINT UNSIGNED NOT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + display_name VARCHAR(100) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + last_login_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_admin_oauth_provider_subject (provider, subject), + UNIQUE KEY uk_admin_oauth_user_provider (admin_user_id, provider), + CONSTRAINT fk_admin_oauth_identity_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_oauth_states ( + state_hash BINARY(32) NOT NULL, + provider VARCHAR(20) NOT NULL, + code_verifier VARCHAR(128) NOT NULL DEFAULT '', + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (state_hash), + KEY idx_admin_oauth_state_expiry (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_oauth_login_codes ( + code_hash BINARY(32) NOT NULL, + provider VARCHAR(20) NOT NULL, + subject VARCHAR(191) NOT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + display_name VARCHAR(100) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + admin_user_id BIGINT UNSIGNED NULL, + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (code_hash), + KEY idx_admin_oauth_code_expiry (expires_at), + KEY idx_admin_oauth_code_admin (admin_user_id), + CONSTRAINT fk_admin_oauth_code_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('oauth.admin.frontend_callback_url', 'http://localhost:5560/auth/social-callback', 'string', '第三方登录完成后跳转的管理端页面;生产环境必须使用 HTTPS'), + + ('oauth.wechat.enabled', 'false', 'boolean', '启用微信扫码登录'), + ('oauth.wechat.client_id', '', 'string', '微信开放平台网站应用 AppID'), + ('oauth.wechat.client_secret', '', 'secret', '微信开放平台网站应用 AppSecret'), + ('oauth.wechat.authorization_url', 'https://open.weixin.qq.com/connect/qrconnect', 'string', '微信登录授权地址'), + ('oauth.wechat.token_url', 'https://api.weixin.qq.com/sns/oauth2/access_token', 'string', '微信登录令牌地址'), + ('oauth.wechat.userinfo_url', 'https://api.weixin.qq.com/sns/userinfo', 'string', '微信用户信息地址'), + ('oauth.wechat.scope', 'snsapi_login', 'string', '微信网站应用登录授权范围'), + ('oauth.wechat.redirect_uri', 'http://127.0.0.1:8888/admin/v1/auth/oauth/callback', 'string', '微信开放平台登记的授权回调地址;生产环境必须使用 HTTPS'), + + ('oauth.qq.enabled', 'false', 'boolean', '启用 QQ 登录'), + ('oauth.qq.client_id', '', 'string', 'QQ 互联应用 AppID'), + ('oauth.qq.client_secret', '', 'secret', 'QQ 互联应用 AppKey'), + ('oauth.qq.authorization_url', 'https://graph.qq.com/oauth2.0/authorize', 'string', 'QQ 登录授权地址'), + ('oauth.qq.token_url', 'https://graph.qq.com/oauth2.0/token', 'string', 'QQ 登录令牌地址'), + ('oauth.qq.openid_url', 'https://graph.qq.com/oauth2.0/me', 'string', 'QQ OpenID 查询地址'), + ('oauth.qq.userinfo_url', 'https://graph.qq.com/user/get_user_info', 'string', 'QQ 用户信息地址'), + ('oauth.qq.scope', 'get_user_info', 'string', 'QQ 登录授权范围'), + ('oauth.qq.redirect_uri', 'http://127.0.0.1:8888/admin/v1/auth/oauth/callback', 'string', 'QQ 互联登记的授权回调地址;生产环境必须使用 HTTPS'), + + ('oauth.github.enabled', 'false', 'boolean', '启用 GitHub 登录'), + ('oauth.github.client_id', '', 'string', 'GitHub OAuth App Client ID'), + ('oauth.github.client_secret', '', 'secret', 'GitHub OAuth App Client Secret'), + ('oauth.github.authorization_url', 'https://github.com/login/oauth/authorize', 'string', 'GitHub OAuth 授权地址'), + ('oauth.github.token_url', 'https://github.com/login/oauth/access_token', 'string', 'GitHub OAuth 令牌地址'), + ('oauth.github.userinfo_url', 'https://api.github.com/user', 'string', 'GitHub 当前用户信息地址'), + ('oauth.github.scope', 'read:user user:email', 'string', 'GitHub 登录最小授权范围'), + ('oauth.github.redirect_uri', 'http://127.0.0.1:8888/admin/v1/auth/oauth/callback', 'string', 'GitHub OAuth App 登记的 callback URL;生产环境必须使用 HTTPS'), + + ('oauth.google.enabled', 'false', 'boolean', '启用 Google 登录'), + ('oauth.google.client_id', '', 'string', 'Google OAuth 2.0 Client ID'), + ('oauth.google.client_secret', '', 'secret', 'Google OAuth 2.0 Client Secret'), + ('oauth.google.authorization_url', 'https://accounts.google.com/o/oauth2/v2/auth', 'string', 'Google OAuth 授权地址'), + ('oauth.google.token_url', 'https://oauth2.googleapis.com/token', 'string', 'Google OAuth 令牌地址'), + ('oauth.google.userinfo_url', 'https://openidconnect.googleapis.com/v1/userinfo', 'string', 'Google OpenID Connect UserInfo 地址'), + ('oauth.google.scope', 'openid profile email', 'string', 'Google 登录授权范围'), + ('oauth.google.redirect_uri', 'http://127.0.0.1:8888/admin/v1/auth/oauth/callback', 'string', 'Google Cloud Console 登记的 redirect URI;生产环境必须使用 HTTPS') +ON DUPLICATE KEY UPDATE description = VALUES(description); diff --git a/im/.deploy/application-20260831-120612/payload/migrations/021_user_oauth_login.sql b/im/.deploy/application-20260831-120612/payload/migrations/021_user_oauth_login.sql new file mode 100644 index 0000000..03e36c1 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/021_user_oauth_login.sql @@ -0,0 +1,54 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS user_oauth_identities ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + provider VARCHAR(20) NOT NULL, + subject VARCHAR(191) NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + display_name VARCHAR(100) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + last_login_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_user_oauth_provider_subject (provider, subject), + UNIQUE KEY uk_user_oauth_user_provider (user_id, provider), + CONSTRAINT fk_user_oauth_identity_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_oauth_states ( + state_hash BINARY(32) NOT NULL, + provider VARCHAR(20) NOT NULL, + code_verifier VARCHAR(128) NOT NULL DEFAULT '', + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (state_hash), + KEY idx_user_oauth_state_expiry (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_oauth_login_codes ( + code_hash BINARY(32) NOT NULL, + provider VARCHAR(20) NOT NULL, + subject VARCHAR(191) NOT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + display_name VARCHAR(100) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + user_id BIGINT UNSIGNED NULL, + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (code_hash), + KEY idx_user_oauth_code_expiry (expires_at), + KEY idx_user_oauth_code_user (user_id), + CONSTRAINT fk_user_oauth_code_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('oauth.user.frontend_callback_url', 'http://localhost:5174/#/pages/auth/oauth-callback', 'string', '第三方登录完成后跳转的 uni-app H5 页面;生产环境必须使用 HTTPS'), + ('oauth.user.wechat.enabled', 'false', 'boolean', '在客户端启用微信登录'), + ('oauth.user.qq.enabled', 'false', 'boolean', '在客户端启用 QQ 登录'), + ('oauth.user.github.enabled', 'false', 'boolean', '在客户端启用 GitHub 登录'), + ('oauth.user.google.enabled', 'false', 'boolean', '在客户端启用 Google 登录') +ON DUPLICATE KEY UPDATE description = VALUES(description); diff --git a/im/.deploy/application-20260831-120612/payload/migrations/022_admin_sessions.sql b/im/.deploy/application-20260831-120612/payload/migrations/022_admin_sessions.sql new file mode 100644 index 0000000..0696e28 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/022_admin_sessions.sql @@ -0,0 +1,17 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS admin_sessions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + admin_user_id BIGINT UNSIGNED NOT NULL, + refresh_token_hash BINARY(32) NOT NULL, + user_agent_hash BINARY(32) NOT NULL, + ip VARCHAR(45) NOT NULL DEFAULT '', + expires_at DATETIME(3) NOT NULL, + last_active_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + revoked_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_admin_sessions_refresh (refresh_token_hash), + KEY idx_admin_sessions_user (admin_user_id, revoked_at, expires_at), + CONSTRAINT fk_admin_sessions_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/im/.deploy/application-20260831-120612/payload/migrations/023_admin_rbac.sql b/im/.deploy/application-20260831-120612/payload/migrations/023_admin_rbac.sql new file mode 100644 index 0000000..47da153 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/023_admin_rbac.sql @@ -0,0 +1,77 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS admin_roles ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + role_code VARCHAR(50) NOT NULL, + role_name VARCHAR(80) NOT NULL, + description VARCHAR(255) NOT NULL DEFAULT '', + built_in TINYINT(1) NOT NULL DEFAULT 0, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_admin_roles_code (role_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_permissions ( + permission_code VARCHAR(80) NOT NULL, + permission_name VARCHAR(100) NOT NULL, + permission_group VARCHAR(50) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + PRIMARY KEY (permission_code), + KEY idx_admin_permissions_group (permission_group, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_role_permissions ( + role_id BIGINT UNSIGNED NOT NULL, + permission_code VARCHAR(80) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (role_id, permission_code), + CONSTRAINT fk_admin_role_permissions_role FOREIGN KEY (role_id) REFERENCES admin_roles(id) ON DELETE CASCADE, + CONSTRAINT fk_admin_role_permissions_permission FOREIGN KEY (permission_code) REFERENCES admin_permissions(permission_code) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_user_roles ( + admin_user_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (admin_user_id, role_id), + KEY idx_admin_user_roles_role (role_id), + CONSTRAINT fk_admin_user_roles_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) ON DELETE CASCADE, + CONSTRAINT fk_admin_user_roles_role FOREIGN KEY (role_id) REFERENCES admin_roles(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO admin_permissions (permission_code, permission_name, permission_group, sort_order) VALUES + ('dashboard:view', '查看运营概览', 'dashboard', 10), + ('users:view', '查看用户', 'users', 20), + ('users:manage', '编辑用户资料和会员', 'users', 21), + ('users:security', '重置密码和强制下线', 'users', 22), + ('verification:manage', '审核用户认证', 'users', 23), + ('violations:manage', '处理违规和账号处罚', 'users', 24), + ('content:view', '查看动态内容', 'content', 30), + ('content:manage', '删除和处置动态', 'content', 31), + ('messages:view', '查看聊天消息记录', 'content', 32), + ('reports:handle', '处理举报', 'risk', 40), + ('risk:view', '查看风险信息', 'risk', 41), + ('membership:manage', '管理会员套餐', 'commerce', 50), + ('orders:view', '查看订单', 'commerce', 51), + ('orders:manage', '变更订单和退款', 'commerce', 52), + ('system:manage', '管理系统、集成和管理员', 'system', 60) +ON DUPLICATE KEY UPDATE + permission_name=VALUES(permission_name), + permission_group=VALUES(permission_group), + sort_order=VALUES(sort_order); + +INSERT INTO admin_roles (role_code, role_name, description, built_in, status) +VALUES ('super_admin', '超级管理员', '拥有平台全部权限;内置角色不可删除', 1, 1) +ON DUPLICATE KEY UPDATE role_name=VALUES(role_name), description=VALUES(description), built_in=1, status=1; + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_code) +SELECT r.id, p.permission_code +FROM admin_roles r CROSS JOIN admin_permissions p +WHERE r.role_code='super_admin'; + +-- Existing administrators keep their current capabilities after RBAC is enabled. +INSERT IGNORE INTO admin_user_roles (admin_user_id, role_id) +SELECT a.id, r.id +FROM admin_users a JOIN admin_roles r ON r.role_code='super_admin'; diff --git a/im/.deploy/application-20260831-120612/payload/migrations/024_user_verification_submission.sql b/im/.deploy/application-20260831-120612/payload/migrations/024_user_verification_submission.sql new file mode 100644 index 0000000..08a9e44 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/024_user_verification_submission.sql @@ -0,0 +1,11 @@ +SET NAMES utf8mb4; + +ALTER TABLE user_verifications + ADD COLUMN document_hash BINARY(32) NULL AFTER document_mask, + ADD COLUMN evidence_json TEXT NULL AFTER document_hash, + ADD KEY idx_user_verifications_document (document_hash); + +UPDATE user_verifications SET evidence_json='[]' WHERE evidence_json IS NULL OR evidence_json=''; + +ALTER TABLE user_verifications + MODIFY COLUMN evidence_json TEXT NOT NULL; diff --git a/im/.deploy/application-20260831-120612/payload/migrations/025_client_productization.sql b/im/.deploy/application-20260831-120612/payload/migrations/025_client_productization.sql new file mode 100644 index 0000000..183194f --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/025_client_productization.sql @@ -0,0 +1,140 @@ +SET NAMES utf8mb4; + +ALTER TABLE user_privacy_settings + ADD COLUMN invisible_visit TINYINT(1) NOT NULL DEFAULT 0 AFTER allow_profile_visit_record; + +ALTER TABLE membership_plans + ADD COLUMN daily_like_limit INT UNSIGNED NOT NULL DEFAULT 20 AFTER daily_active_chat_limit, + ADD COLUMN can_view_visitors TINYINT(1) NOT NULL DEFAULT 0 AFTER daily_like_limit, + ADD COLUMN can_invisible_visit TINYINT(1) NOT NULL DEFAULT 0 AFTER can_view_visitors, + ADD COLUMN recommendation_weight INT UNSIGNED NOT NULL DEFAULT 0 AFTER can_invisible_visit; + +UPDATE membership_plans +SET daily_like_limit=CASE WHEN level>=2 THEN 0 WHEN level=1 THEN 100 ELSE 20 END, + can_view_visitors=IF(level>=1,1,0), + can_invisible_visit=IF(level>=1,1,0), + recommendation_weight=CASE WHEN level>=2 THEN 30 WHEN level=1 THEN 15 ELSE 0 END; + +ALTER TABLE user_verifications + DROP PRIMARY KEY, + ADD PRIMARY KEY (user_id, verification_type); + +CREATE TABLE IF NOT EXISTS user_daily_like_usage ( + user_id BIGINT UNSIGNED NOT NULL, + usage_date DATE NOT NULL, + used_count INT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, usage_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_daily_like_targets ( + user_id BIGINT UNSIGNED NOT NULL, + target_type VARCHAR(20) NOT NULL, + target_id BIGINT UNSIGNED NOT NULL, + usage_date DATE NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, target_type, target_id, usage_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_notification_settings ( + user_id BIGINT UNSIGNED NOT NULL, + im_enabled TINYINT(1) NOT NULL DEFAULT 1, + interaction_enabled TINYINT(1) NOT NULL DEFAULT 1, + system_enabled TINYINT(1) NOT NULL DEFAULT 1, + sound_enabled TINYINT(1) NOT NULL DEFAULT 1, + vibration_enabled TINYINT(1) NOT NULL DEFAULT 1, + quiet_start VARCHAR(5) NOT NULL DEFAULT '', + quiet_end VARCHAR(5) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_push_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + device_id VARCHAR(100) NOT NULL, + provider VARCHAR(30) NOT NULL, + push_token VARCHAR(500) NOT NULL, + platform VARCHAR(20) NOT NULL DEFAULT '', + app_version VARCHAR(30) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + last_active_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_push_device_provider (user_id, device_id, provider), + KEY idx_push_user_status (user_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_feedback ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + category VARCHAR(30) NOT NULL, + content VARCHAR(2000) NOT NULL, + contact VARCHAR(100) NOT NULL DEFAULT '', + evidence_json JSON NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + reply_content VARCHAR(2000) NOT NULL DEFAULT '', + handled_by BIGINT UNSIGNED NULL, + handled_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_feedback_user_created (user_id, created_at), + KEY idx_feedback_status_created (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_account_closures ( + user_id BIGINT UNSIGNED NOT NULL, + reason VARCHAR(500) NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + requested_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + execute_after DATETIME(3) NOT NULL, + cancelled_at DATETIME(3) NULL, + completed_at DATETIME(3) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_closure_status_execute (status, execute_after) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_consents ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + agreement_type VARCHAR(30) NOT NULL, + agreement_version VARCHAR(30) NOT NULL, + accepted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + ip VARCHAR(45) NOT NULL DEFAULT '', + device_id VARCHAR(100) NOT NULL DEFAULT '', + PRIMARY KEY (id), + UNIQUE KEY uk_user_consent_version (user_id, agreement_type, agreement_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE orders + ADD COLUMN refund_reason VARCHAR(500) NOT NULL DEFAULT '' AFTER payment_notified_at, + ADD COLUMN refund_requested_at DATETIME(3) NULL AFTER refund_reason; + +INSERT INTO user_notification_settings(user_id) +SELECT id FROM users +ON DUPLICATE KEY UPDATE user_id=VALUES(user_id); + +INSERT INTO system_configs (config_key,config_value,value_type,description) VALUES + ('membership.free_daily_like_limit','20','number','普通用户每日点赞次数'), + ('account.cancellation_cooling_days','7','number','账号注销冷静期天数'), + ('legal.user_agreement_version','1.0','string','用户协议版本'), + ('legal.privacy_policy_version','1.0','string','隐私政策版本'), + ('legal.operator_name','','string','平台运营主体全称(上线前必填)'), + ('legal.contact','','string','隐私与客服联系方式(上线前必填)'), + ('legal.effective_date','','string','协议生效日期,格式 YYYY-MM-DD'), + ('legal.user_agreement_url','','string','完整用户协议 HTTPS 地址'), + ('legal.privacy_policy_url','','string','完整隐私政策 HTTPS 地址'), + ('app.maintenance.enabled','false','boolean','客户端维护模式'), + ('app.maintenance.message','系统维护中,请稍后再试','string','客户端维护提示'), + ('app.features.nearby','true','boolean','附近功能开关'), + ('app.features.feed','true','boolean','动态功能开关'), + ('app.features.membership','true','boolean','会员功能开关'), + ('app.features.im','true','boolean','即时通讯功能开关'), + ('app.min_version.android','1.0.0','string','Android 最低版本'), + ('app.min_version.ios','1.0.0','string','iOS 最低版本'), + ('app.min_version.h5','1.0.0','string','H5 最低版本') +ON DUPLICATE KEY UPDATE description=VALUES(description),value_type=VALUES(value_type); diff --git a/im/.deploy/application-20260831-120612/payload/migrations/026_report_operations.sql b/im/.deploy/application-20260831-120612/payload/migrations/026_report_operations.sql new file mode 100644 index 0000000..2a01644 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/026_report_operations.sql @@ -0,0 +1,17 @@ +ALTER TABLE reports + ADD COLUMN action_type VARCHAR(30) NOT NULL DEFAULT '' AFTER status, + ADD COLUMN handle_remark VARCHAR(1000) NOT NULL DEFAULT '' AFTER action_type; + +CREATE INDEX idx_reports_target ON reports(target_type, target_id, created_at); + +ALTER TABLE im_messages + ADD COLUMN admin_removed_at DATETIME(3) NULL AFTER recalled_at, + ADD COLUMN admin_removed_by BIGINT UNSIGNED NULL AFTER admin_removed_at, + ADD COLUMN admin_remove_reason VARCHAR(500) NOT NULL DEFAULT '' AFTER admin_removed_by; + +INSERT INTO admin_permissions(permission_code,permission_name,permission_group,sort_order) +VALUES('messages:manage','下架及恢复违规聊天消息','content',33) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),permission_group=VALUES(permission_group),sort_order=VALUES(sort_order); + +INSERT IGNORE INTO admin_role_permissions(role_id,permission_code) +SELECT id,'messages:manage' FROM admin_roles WHERE role_code='super_admin'; diff --git a/im/.deploy/application-20260831-120612/payload/migrations/027_app_oauth_login.sql b/im/.deploy/application-20260831-120612/payload/migrations/027_app_oauth_login.sql new file mode 100644 index 0000000..167ae2b --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/027_app_oauth_login.sql @@ -0,0 +1,31 @@ +SET NAMES utf8mb4; + +ALTER TABLE user_oauth_states + ADD COLUMN client_platform VARCHAR(10) NOT NULL DEFAULT 'h5', + ADD COLUMN app_proof_hash BINARY(32) NULL; + +ALTER TABLE user_oauth_login_codes + ADD COLUMN client_platform VARCHAR(10) NOT NULL DEFAULT 'h5', + ADD COLUMN app_proof_hash BINARY(32) NULL, + ADD COLUMN identity_scope VARCHAR(191) NOT NULL DEFAULT ''; + +-- OpenID is scoped to the provider's application. Keep existing H5 bindings, +-- and allow a user to bind the mobile application identity independently. +ALTER TABLE user_oauth_identities + ADD COLUMN identity_scope VARCHAR(191) NOT NULL DEFAULT '', + DROP INDEX uk_user_oauth_provider_subject, + DROP INDEX uk_user_oauth_user_provider, + ADD UNIQUE KEY uk_user_oauth_provider_subject (provider, identity_scope, subject), + ADD UNIQUE KEY uk_user_oauth_user_provider (user_id, provider, identity_scope); + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('oauth.app.frontend_callback_url', 'xingyuim://oauth/callback', 'string', 'App GitHub 授权结果地址,必须与打包的 URL Scheme 一致'), + ('oauth.app.wechat.enabled', 'false', 'boolean', '在 App 启用微信原生登录'), + ('oauth.app.qq.enabled', 'false', 'boolean', '在 App 启用 QQ 原生登录'), + ('oauth.app.github.enabled', 'false', 'boolean', '在 App 启用 GitHub 系统浏览器登录'), + ('oauth.app.google.enabled', 'false', 'boolean', '在 App 启用 Google 原生登录'), + ('oauth.app.wechat.client_id', '', 'string', '微信开放平台移动应用 AppID'), + ('oauth.app.wechat.client_secret', '', 'secret', '微信移动应用 AppSecret,仅服务端保存'), + ('oauth.app.qq.client_id', '', 'string', 'QQ 互联移动应用 AppID'), + ('oauth.app.google.client_ids', '', 'string', '允许的 Google Android/iOS OAuth Client ID,以英文逗号分隔') +ON DUPLICATE KEY UPDATE description = VALUES(description); diff --git a/im/.deploy/application-20260831-120612/payload/migrations/028_test_users.sql b/im/.deploy/application-20260831-120612/payload/migrations/028_test_users.sql new file mode 100644 index 0000000..e9e6321 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/028_test_users.sql @@ -0,0 +1,7 @@ +SET NAMES utf8mb4; + +-- Registration does not set these fields: genuine users remain non-test. +ALTER TABLE users + ADD COLUMN is_test TINYINT(1) NOT NULL DEFAULT 0, + ADD COLUMN test_batch VARCHAR(64) NOT NULL DEFAULT '', + ADD KEY idx_users_test_batch (is_test, test_batch, id); diff --git a/im/.deploy/application-20260831-120612/payload/migrations/029_admin_create_users.sql b/im/.deploy/application-20260831-120612/payload/migrations/029_admin_create_users.sql new file mode 100644 index 0000000..ab25065 --- /dev/null +++ b/im/.deploy/application-20260831-120612/payload/migrations/029_admin_create_users.sql @@ -0,0 +1,9 @@ +SET NAMES utf8mb4; + +INSERT INTO admin_permissions (permission_code, permission_name, permission_group, sort_order) +VALUES ('users:create', '新增客户端用户', 'users', 25) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), permission_group=VALUES(permission_group), sort_order=VALUES(sort_order); + +-- Grant to the built-in administrator role; other roles require an explicit grant. +INSERT IGNORE INTO admin_role_permissions (role_id, permission_code) +SELECT id, 'users:create' FROM admin_roles WHERE role_code='super_admin'; diff --git a/im/.deploy/application-20260831-120612/payload/xingyu-api b/im/.deploy/application-20260831-120612/payload/xingyu-api new file mode 100644 index 0000000..171f36d Binary files /dev/null and b/im/.deploy/application-20260831-120612/payload/xingyu-api differ diff --git a/im/.deploy/application-20260831-120612/payload/xingyu-api-rollback b/im/.deploy/application-20260831-120612/payload/xingyu-api-rollback new file mode 100644 index 0000000..286361d Binary files /dev/null and b/im/.deploy/application-20260831-120612/payload/xingyu-api-rollback differ diff --git a/im/.deploy/application-20260831-120612/public-verification.json b/im/.deploy/application-20260831-120612/public-verification.json new file mode 100644 index 0000000..93b796a --- /dev/null +++ b/im/.deploy/application-20260831-120612/public-verification.json @@ -0,0 +1,21 @@ +{ + "domain": "https://im.bchongw.com", + "tlsVerified": true, + "entrypointsMatchRelease": true, + "assetHashesVerified": 5, + "checks": { + "/admin/": 200, + "/app/": 200, + "/admin/_app.config.js": 200, + "/app/assets/uni.db7a9edc.css": 200, + "/app/assets/index-D6nTj_fj.css": 200, + "/admin/jse/index-index-aHiFtf9y.js": 200, + "/app/assets/index-CBqRkczm.js": 200, + "/healthz": 200, + "/api/v1/auth/oauth/providers?platform=app": 200, + "/api/v1/auth/oauth/providers?platform=h5": 200, + "/admin/v1/users": 401, + "/admin/v1/auth/codes": 401, + "/ws": 401 + } +} \ No newline at end of file diff --git a/im/.deploy/application-20260831-120612/release-local.json b/im/.deploy/application-20260831-120612/release-local.json new file mode 100644 index 0000000..c5eddb9 --- /dev/null +++ b/im/.deploy/application-20260831-120612/release-local.json @@ -0,0 +1,8 @@ +{ + "root": "D:\\web\\age\\im\\.deploy\\application-20260831-120612", + "expectedPreviousBackend": "92b306000ab787e2eb20e69daf25cc3374d082a88f7301e24811a2b7d0022d9d", + "allowedMigrations": [ + "027_app_oauth_login.sql", + "029_admin_create_users.sql" + ] +} \ No newline at end of file diff --git a/im/.deploy/application-20260831-120612/release.sha256 b/im/.deploy/application-20260831-120612/release.sha256 new file mode 100644 index 0000000..3056384 --- /dev/null +++ b/im/.deploy/application-20260831-120612/release.sha256 @@ -0,0 +1 @@ +865952392f733cefcdd9ad257fd701a55be57c434a55f381a7045a5b035d0c17 \ No newline at end of file diff --git a/im/.deploy/application-20260831-120612/release.zip b/im/.deploy/application-20260831-120612/release.zip new file mode 100644 index 0000000..ce65f1c Binary files /dev/null and b/im/.deploy/application-20260831-120612/release.zip differ diff --git a/im/.deploy/application-20260831-120612/remote_exec.py b/im/.deploy/application-20260831-120612/remote_exec.py new file mode 100644 index 0000000..2477f0c --- /dev/null +++ b/im/.deploy/application-20260831-120612/remote_exec.py @@ -0,0 +1,47 @@ +import json +import re +import shlex +import sys +from pathlib import Path + +import paramiko + +# Reuse the user's existing project deployment credentials without printing or +# copying them into a release artifact. Host identity must match known_hosts. +history = Path.home() / '.codex/sessions/2026/08/24/rollout-2026-08-24T11-25-28-01a031cd-3aa2-7563-8b60-bddb04bafa8f.jsonl' +password = None +for line in history.open(encoding='utf-8'): + record = json.loads(line) + if record.get('timestamp') != '2026-08-31T03:47:09.684Z': + continue + match = re.search(r'["\']?chars["\']?\s*:\s*("(?:[^"\\]|\\.)*")', record.get('payload', {}).get('input', '')) + if match: + password = json.loads(match[1]).rstrip('\r\n') +assert password +client = paramiko.SSHClient() +client.load_host_keys(str(Path.home() / '.ssh/known_hosts')) +client.set_missing_host_key_policy(paramiko.RejectPolicy()) +client.connect('47.106.181.28', username='root', password=password, look_for_keys=False, allow_agent=False, timeout=15) +password = None +try: + mode, local = sys.argv[1:3] + if mode == 'upload': + with client.open_sftp() as sftp: + sftp.put(local, sys.argv[3]) + print('Uploaded ' + Path(local).name, flush=True) + elif mode == 'run': + command = '/www/server/panel/pyenv/bin/python - ' + ' '.join(shlex.quote(arg) for arg in sys.argv[3:]) + stdin, stdout, stderr = client.exec_command(command, timeout=1200) + stdin.write(Path(local).read_text(encoding='utf-8')) + stdin.channel.shutdown_write() + for line in stdout: + print(line, end='', flush=True) + error = stderr.read().decode('utf-8', errors='replace') + status = stdout.channel.recv_exit_status() + if error: + print(error, file=sys.stderr) + raise SystemExit(status) + else: + raise ValueError('unsupported operation') +finally: + client.close() diff --git a/im/.deploy/application-20260831-120612/rollback-backend/cmd/seed-test-users/main.go b/im/.deploy/application-20260831-120612/rollback-backend/cmd/seed-test-users/main.go new file mode 100644 index 0000000..32030f8 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/cmd/seed-test-users/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "database/sql" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "time" + + "github.com/example/xingyu/internal/testusers" + "github.com/go-sql-driver/mysql" +) + +func main() { + apply := flag.Bool("apply", false, "write the 100-user batch (default: print JSON only)") + base := flag.String("public-base", "", "public media URL, e.g. https://im.bchongw.com/uploads") + confirm := flag.String("confirm-database", "", "exact target database name, required for --apply") + production := flag.Bool("allow-production-test-data", false, "confirm intentional production fixture import") + source := flag.String("avatars-dir", "../fixtures/test-users/avatars", "generated avatar source directory") + media := flag.String("media-dir", "", "actual configured storage.local.directory / IM_MEDIA_DIR") + flag.Parse() + profiles, err := testusers.Generate(*base) + if err != nil { + log.Fatal(err) + } + if !*apply { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err = encoder.Encode(profiles); err != nil { + log.Fatal(err) + } + return + } + if os.Getenv("IM_ENV") != "development" && os.Getenv("IM_ENV") != "test" && !*production { + log.Fatal("set IM_ENV=development/test, or explicitly confirm with --allow-production-test-data") + } + dsn := os.Getenv("IM_DB_DSN") + cfg, err := mysql.ParseDSN(dsn) + if err != nil || dsn == "" || *confirm == "" || cfg.DBName != *confirm { + log.Fatal("set IM_DB_DSN and matching --confirm-database; no default database is used") + } + cfg.ParseTime = true + db, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + log.Fatal("cannot open database") + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + if err = db.PingContext(ctx); err != nil { + log.Fatal("database connection failed (credentials not logged)") + } + if err = testusers.CopyAvatars(*source, *media); err != nil { + log.Fatal(err) + } + result, err := testusers.Seed(ctx, db, *base, *confirm) + if err != nil { + log.Fatal(err) + } + data, _ := json.MarshalIndent(result, "", " ") + fmt.Println(string(data)) +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/cmd/server/main.go b/im/.deploy/application-20260831-120612/rollback-backend/cmd/server/main.go new file mode 100644 index 0000000..3c528ee --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/cmd/server/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "log" + + "github.com/example/xingyu/internal/app" +) + +func main() { + service, err := app.New(app.LoadConfig()) + if err != nil { + log.Fatal(err) + } + defer service.Close() + if err := service.Seed(); err != nil { + log.Fatal(err) + } + service.Run() +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/go.mod b/im/.deploy/application-20260831-120612/rollback-backend/go.mod new file mode 100644 index 0000000..1fc8a80 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/go.mod @@ -0,0 +1,73 @@ +module github.com/example/xingyu + +go 1.27.0 + +require ( + github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.6.0 + github.com/go-sql-driver/mysql v1.10.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/gorilla/websocket v1.5.3 + github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible + github.com/qiniu/go-sdk/v7 v7.27.0 + github.com/tencentyun/cos-go-sdk-v5 v0.7.75 + github.com/zeromicro/go-zero v1.10.3 + golang.org/x/crypto v0.48.0 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/BurntSushi/toml v1.3.2 // indirect + github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clbanning/mxj v1.8.4 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gofrs/flock v0.8.1 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/google/go-querystring v1.0.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grafana/pyroscope-go v1.3.0 // indirect + github.com/grafana/pyroscope-go/godeltaprof v0.1.10 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.4.3 // indirect + github.com/mozillazg/go-httpheader v0.2.1 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/openzipkin/zipkin-go v0.4.3 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/titanous/json5 v1.0.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/zipkin v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/net v0.50.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 + golang.org/x/time v0.14.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + modernc.org/fileutil v1.0.0 // indirect +) diff --git a/im/.deploy/application-20260831-120612/rollback-backend/go.sum b/im/.deploy/application-20260831-120612/rollback-backend/go.sum new file mode 100644 index 0000000..87ba84e --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/go.sum @@ -0,0 +1,187 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 h1:7dONQ3WNZ1zy960TmkxJPuwoolZwL7xKtpcM04MBnt4= +github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82/go.mod h1:nLnM0KdK1CmygvjpDUO6m1TjSsiQtL61juhNsvV/JVI= +github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.6.0 h1:uWzn3io54f9L9mvwsQQSv1KpkkFA06hBxI++RvIyvpI= +github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.6.0/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I= +github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grafana/pyroscope-go v1.3.0 h1:t3Jehad8vvqN4oRAB0LdmfQ5ZSUXQw3asoft+K4GAT8= +github.com/grafana/pyroscope-go v1.3.0/go.mod h1:XA7I3usNx+UdjOZfQnl1WV8y924vsJo9KIVrKB+9jx4= +github.com/grafana/pyroscope-go/godeltaprof v0.1.10 h1:dvhndEbyavTb59vFCd6PsrAG5qi69/qZZtegh/TJKSY= +github.com/grafana/pyroscope-go/godeltaprof v0.1.10/go.mod h1:XnWRGg2XO5uxZdiz1rfeJH6w1eZ+YICCBVXNWOfH86g= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= +github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible h1:lX3m9hvP5tSnJ8bFg/TdT2BYHj1nSBulealy5VN9mPU= +github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible/go.mod h1:l7VUhRbTKCzdOacdT4oWCwATKyvZqUOlOqr0Ous3k4s= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +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= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs= +github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mozillazg/go-httpheader v0.2.1 h1:geV7TrjbL8KXSyvghnFm+NyTux/hxwueTSrwhe88TQQ= +github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg= +github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/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/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/qiniu/go-sdk/v7 v7.27.0 h1:n+2U0S5fhbmG/lN/agO8KcYJaQDqROUVShtnp56Mkw8= +github.com/qiniu/go-sdk/v7 v7.27.0/go.mod h1:pTwVR1B+8SXcPLhDzBUasiKFTD9F7jRglRDR553BW3k= +github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGGF0= +github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY= +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/rs/dnscache v0.0.0-20230804202142-fc85eb664529/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +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/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0= +github.com/tencentyun/cos-go-sdk-v5 v0.7.75 h1:eRCGP5chujSYGFnsCPxlhQcN9wtlSO/4eXrxKtLVAIw= +github.com/tencentyun/cos-go-sdk-v5 v0.7.75/go.mod h1:STbTNaNKq03u+gscPEGOahKzLcGSYOj6Dzc5zNay7Pg= +github.com/tencentyun/qcloud-cos-sts-sdk v0.0.0-20250515025012-e0eec8a5d123/go.mod h1:b18KQa4IxHbxeseW1GcZox53d7J0z39VNONTxvvlkXw= +github.com/titanous/json5 v1.0.0 h1:hJf8Su1d9NuI/ffpxgxQfxh/UiBFZX7bMPid0rIL/7s= +github.com/titanous/json5 v1.0.0/go.mod h1:7JH1M8/LHKc6cyP5o5g3CSaRj+mBrIimTxzpvmckH8c= +github.com/zeromicro/go-zero v1.10.3 h1:fm4+jUuUF77IWtFeAyf2xVoBRcgEpF1NZJUqTvZ3dw0= +github.com/zeromicro/go-zero v1.10.3/go.mod h1:Gnac2bT/JGb9Ja79wchssVeYtJxuWWzL98DuLH11kds= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8= +go.opentelemetry.io/otel/exporters/zipkin v1.40.0 h1:zu+I4j+FdO6xIxBVPeuncQVbjxUM4LiMgv6GwGe9REE= +go.opentelemetry.io/otel/exporters/zipkin v1.40.0/go.mod h1:zS6cC4nFBYXbu18e7aLfMzubBjOiN7ZcROu477qtMf8= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= +gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= +gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= +gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +modernc.org/fileutil v1.0.0 h1:Z1AFLZwl6BO8A5NldQg/xTSjGLetp+1Ubvl4alfGx8w= +modernc.org/fileutil v1.0.0/go.mod h1:JHsWpkrk/CnVV1H/eGlFf85BEpfkrp56ro8nojIq9Q8= diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin.go new file mode 100644 index 0000000..1ce8b82 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin.go @@ -0,0 +1,826 @@ +package app + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +func (a *App) adminLogin(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "请输入账号和密码") + return + } + if !a.rateLimit(w, r, "admin_login_ip", clientIP(r), 10, 10*time.Minute) || !a.rateLimit(w, r, "admin_login_user", strings.ToLower(strings.TrimSpace(req.Username)), 10, 15*time.Minute) { + return + } + var id int64 + var hash, realName string + var status int + err := a.db.QueryRowContext(r.Context(), `SELECT id,password_hash,real_name,status FROM admin_users WHERE username=?`, req.Username).Scan(&id, &hash, &realName, &status) + if err != nil || !checkPassword(hash, req.Password) { + fail(w, 401, 10001, "账号或密码错误") + return + } + if status != 1 { + fail(w, 403, 10006, "管理员账号已停用") + return + } + payload, err := a.newAdminSession(r.Context(), w, r, id, realName) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建管理会话失败") + return + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE admin_users SET last_login_at=NOW(3) WHERE id=?`, id) + reply(w, payload) +} + +func (a *App) adminChangePassword(w http.ResponseWriter, r *http.Request) { + who := current(r) + if !a.rateLimit(w, r, "admin_password_change", strconv.FormatInt(who.ID, 10), 5, 30*time.Minute) { + return + } + var req struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` + } + if decode(r, &req) != nil || req.CurrentPassword == "" || req.NewPassword == "" { + fail(w, http.StatusBadRequest, 20001, "请输入当前密码和新密码") + return + } + if !strongAdminPassword(req.NewPassword) { + fail(w, http.StatusBadRequest, 20001, "新密码至少 12 位,且必须包含大小写字母、数字和特殊字符") + return + } + + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "修改密码失败") + return + } + defer func() { _ = tx.Rollback() }() + + var passwordHash string + if err = tx.QueryRowContext(r.Context(), `SELECT password_hash FROM admin_users WHERE id=? AND status=1 FOR UPDATE`, who.ID).Scan(&passwordHash); err != nil || !checkPassword(passwordHash, req.CurrentPassword) { + fail(w, http.StatusBadRequest, 20001, "当前密码错误") + return + } + if checkPassword(passwordHash, req.NewPassword) { + fail(w, http.StatusBadRequest, 20001, "新密码不能与当前密码相同") + return + } + newHash, err := hashPassword(req.NewPassword) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "密码加密失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_users SET password_hash=?,token_version=token_version+1,password_changed_at=NOW(3) WHERE id=?`, newHash, who.ID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "修改密码失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_sessions SET revoked_at=NOW(3) WHERE admin_user_id=? AND revoked_at IS NULL`, who.ID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "撤销旧会话失败") + return + } + if err = tx.Commit(); err != nil { + fail(w, http.StatusInternalServerError, 50001, "修改密码失败") + return + } + a.clearAdminRefreshCookie(w) + a.audit(r, "change_password", "admin_user", who.ID, map[string]any{"allSessionsRevoked": true}) + reply(w, map[string]bool{"success": true, "reauthenticate": true}) +} + +func (a *App) adminInfo(w http.ResponseWriter, r *http.Request) { + who := current(r) + var username, realName, avatar string + if err := a.db.QueryRowContext(r.Context(), `SELECT username,real_name,avatar_url FROM admin_users WHERE id=?`, who.ID).Scan(&username, &realName, &avatar); err != nil { + fail(w, http.StatusUnauthorized, 10001, "管理员不存在") + return + } + roles, permissions, err := a.adminAccess(r.Context(), who.ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取管理员角色失败") + return + } + homePath := "/profile" + permissionHomes := []struct { + Code string + Path string + }{ + {"dashboard:view", "/analytics"}, + {"users:view", "/users"}, + {"content:view", "/posts"}, + {"messages:view", "/messages"}, + {"reports:handle", "/reports"}, + {"risk:view", "/risk"}, + {"membership:manage", "/membership"}, + {"orders:view", "/orders"}, + {"system:manage", "/configs"}, + } + permissionSet := map[string]bool{} + for _, permission := range permissions { + permissionSet[permission] = true + } + for _, candidate := range permissionHomes { + if permissionSet[candidate.Code] { + homePath = candidate.Path + break + } + } + reply(w, map[string]any{"userId": who.ID, "username": username, "realName": realName, "avatar": avatar, "roles": roles, "homePath": homePath}) +} + +func (a *App) dashboard(w http.ResponseWriter, r *http.Request) { + count := func(query string) int64 { + var value int64 + _ = a.db.QueryRowContext(r.Context(), query).Scan(&value) + return value + } + amount := func(query string) int64 { + var value sql.NullInt64 + _ = a.db.QueryRowContext(r.Context(), query).Scan(&value) + if value.Valid { + return value.Int64 + } + return 0 + } + trend := []map[string]any{} + for i := 6; i >= 0; i-- { + day := time.Now().AddDate(0, 0, -i) + var users, messages int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE is_test=0 AND DATE(created_at)=?`, day.Format("2006-01-02")).Scan(&users) + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM im_messages WHERE DATE(created_at)=?`, day.Format("2006-01-02")).Scan(&messages) + trend = append(trend, map[string]any{"date": day.Format("01-02"), "users": users, "messages": messages}) + } + reply(w, map[string]any{"metrics": map[string]any{"testUsers": count(`SELECT COUNT(*) FROM users WHERE is_test=1 AND deleted_at IS NULL`), "users": count(`SELECT COUNT(*) FROM users WHERE is_test=0 AND deleted_at IS NULL`), "newUsersToday": count(`SELECT COUNT(*) FROM users WHERE is_test=0 AND DATE(created_at)=CURDATE()`), "activeUsers": count(`SELECT COUNT(*) FROM user_profiles p JOIN users u ON u.id=p.user_id WHERE u.is_test=0 AND u.deleted_at IS NULL AND p.last_active_at>DATE_SUB(NOW(),INTERVAL 1 DAY)`), "posts": count(`SELECT COUNT(*) FROM posts WHERE status=1`), "messages": count(`SELECT COUNT(*) FROM im_messages`), "pendingReports": count(`SELECT COUNT(*) FROM reports WHERE status='PENDING'`), "paidOrders": count(`SELECT COUNT(*) FROM orders WHERE status='PAID' AND deleted_at IS NULL`), "revenueCent": amount(`SELECT SUM(amount_cent) FROM orders WHERE status='PAID' AND deleted_at IS NULL`)}, "trend": trend, "funnel": []map[string]any{{"name": "注册用户", "value": count(`SELECT COUNT(*) FROM users WHERE is_test=0`)}, {"name": "完善资料", "value": count(`SELECT COUNT(*) FROM user_profiles p JOIN users u ON u.id=p.user_id WHERE u.is_test=0 AND p.profile_score>=80`)}, {"name": "产生互动", "value": count(`SELECT COUNT(DISTINCT user_id) FROM user_likes`)}, {"name": "发起会话", "value": count(`SELECT COUNT(DISTINCT user_id) FROM im_conversation_members`)}, {"name": "付费会员", "value": count(`SELECT COUNT(DISTINCT user_id) FROM orders WHERE status='PAID' AND deleted_at IS NULL`)}}}) +} + +func (a *App) adminUsers(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + keyword := strings.TrimSpace(r.URL.Query().Get("keyword")) + status, _ := strconv.Atoi(r.URL.Query().Get("status")) + verificationStatus := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("verificationStatus"))) + where := ` WHERE u.deleted_at IS NULL` + args := []any{} + sourceWhere, sourceArgs, sourceErr := testUserFilter(r.URL.Query().Get("userType"), r.URL.Query().Get("testBatch")) + if sourceErr != nil { + fail(w, http.StatusBadRequest, 20001, sourceErr.Error()) + return + } + where += sourceWhere + args = append(args, sourceArgs...) + if keyword != "" { + where += ` AND (p.nickname LIKE ? OR u.public_id LIKE ? OR u.phone_hash=?)` + like := "%" + keyword + "%" + args = append(args, like, like, phoneHash(keyword)) + } + if status > 0 { + where += ` AND u.status=?` + args = append(args, status) + } + if verificationStatus != "" { + valid := verificationStatus == "UNVERIFIED" || verificationStatus == "PENDING" || verificationStatus == "VERIFIED" || verificationStatus == "REJECTED" + if !valid { + fail(w, http.StatusBadRequest, 20001, "认证状态筛选无效") + return + } + if verificationStatus == "UNVERIFIED" { + where += ` AND NOT EXISTS(SELECT 1 FROM user_verifications vx WHERE vx.user_id=u.id AND vx.verification_type='real_name' AND vx.status<>'UNVERIFIED')` + } else { + where += ` AND EXISTS(SELECT 1 FROM user_verifications vx WHERE vx.user_id=u.id AND vx.verification_type='real_name' AND vx.status=?)` + args = append(args, verificationStatus) + } + } + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users u JOIN user_profiles p ON p.user_id=u.id`+where, args...).Scan(&total) + query := `SELECT u.id,u.public_id,u.phone_cipher,u.status,u.risk_level,u.created_at,u.is_test,u.test_batch,p.nickname,p.avatar_url,p.gender,p.city_name,p.is_vip,p.vip_level,p.last_active_at,COALESCE(v.status,'UNVERIFIED'),(SELECT MAX(s.expires_at) FROM subscriptions s WHERE s.user_id=u.id AND s.status=1 AND s.expires_at>NOW(3)) FROM users u JOIN user_profiles p ON p.user_id=u.id LEFT JOIN user_verifications v ON v.user_id=u.id AND v.verification_type='real_name'` + where + ` ORDER BY u.created_at DESC,u.id DESC LIMIT ? OFFSET ?` + args = append(args, size, offset) + rows, err := a.db.QueryContext(r.Context(), query, args...) + if err != nil { + fail(w, 500, 50001, err.Error()) + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var publicID, nickname, avatar, city, verificationStatus string + var phoneCipher []byte + var status, risk, gender, vip, vipLevel int + var created time.Time + var active, vipExpiresAt sql.NullTime + var isTest bool + var testBatch string + if err = rows.Scan(&id, &publicID, &phoneCipher, &status, &risk, &created, &isTest, &testBatch, &nickname, &avatar, &gender, &city, &vip, &vipLevel, &active, &verificationStatus, &vipExpiresAt); err != nil { + fail(w, 500, 50001, "读取用户列表失败") + return + } + phone, _ := a.decryptPhone(phoneCipher) + items = append(items, map[string]any{"id": id, "publicId": publicID, "isTest": isTest, "testBatch": testBatch, "phone": maskPhone(phone), "nickname": nickname, "avatar": avatar, "gender": gender, "city": city, "vip": vip > 0, "vipLevel": vipLevel, "vipExpiresAt": nullableTime(vipExpiresAt), "verificationStatus": verificationStatus, "status": status, "riskLevel": risk, "lastActiveAt": nullableTime(active), "createdAt": created}) + } + if rows.Err() != nil { + fail(w, 500, 50001, "读取用户列表失败") + return + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} + +func (a *App) adminUserDetail(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "invalid id") + return + } + a.loadAdminUserDetail(w, r, id) +} + +func (a *App) adminUserStatus(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "invalid id") + return + } + status := 1 + action := "unban" + switch { + case strings.HasSuffix(r.URL.Path, "/freeze"): + status = 2 + action = "freeze" + case strings.HasSuffix(r.URL.Path, "/ban"): + status = 3 + action = "ban" + case strings.HasSuffix(r.URL.Path, "/unfreeze"): + action = "unfreeze" + } + var exists int + if a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE id=? AND deleted_at IS NULL`, id).Scan(&exists) != nil || exists == 0 { + fail(w, 404, 30001, "用户不存在") + return + } + if status != 1 { + typ := "FREEZE" + if status == 3 { + typ = "BAN" + } + if _, err = a.createSanction(r.Context(), current(r).ID, id, typ, "管理员执行账号状态操作", nil); err != nil { + fail(w, 500, 50001, "操作失败") + return + } + } else { + typ := "FREEZE" + if action == "unban" { + typ = "BAN" + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE user_sanctions SET status='REVOKED',revoked_by=?,revoked_at=NOW(3) WHERE user_id=? AND sanction_type=? AND status='ACTIVE'`, current(r).ID, id, typ) + var bans, freezes int + _ = a.db.QueryRowContext(r.Context(), `SELECT SUM(sanction_type='BAN'),SUM(sanction_type='FREEZE') FROM user_sanctions WHERE user_id=? AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>NOW(3))`, id).Scan(&bans, &freezes) + if bans > 0 { + status = 3 + } else if freezes > 0 { + status = 2 + } + _, err = a.db.ExecContext(r.Context(), `UPDATE users SET status=? WHERE id=?`, status, id) + if err != nil { + fail(w, 500, 50001, "操作失败") + return + } + } + a.audit(r, action, "user", id, map[string]any{"status": status}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminPosts(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL`).Scan(&total) + rows, err := a.db.QueryContext(r.Context(), `SELECT po.id,po.content,po.location_text,po.status,po.moderation_status,po.like_count,po.comment_count,po.created_at,p.user_id,p.nickname,p.avatar_url FROM posts po JOIN user_profiles p ON p.user_id=po.user_id WHERE po.deleted_at IS NULL ORDER BY po.created_at DESC LIMIT ? OFFSET ?`, size, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, userID int64 + var content, location, nickname, avatar string + var status, moderation, likes, comments int + var created time.Time + _ = rows.Scan(&id, &content, &location, &status, &moderation, &likes, &comments, &created, &userID, &nickname, &avatar) + items = append(items, map[string]any{"id": id, "content": content, "location": location, "status": status, "moderationStatus": moderation, "likeCount": likes, "commentCount": comments, "createdAt": created, "user": map[string]any{"id": userID, "nickname": nickname, "avatar": avatar}}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} + +func (a *App) adminPostDetail(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态 ID 无效") + return + } + var userID int64 + var publicID, nickname, avatar, cityName, content, cityCode, location string + var gender, vip, visibility, status, moderation, likes, comments int + var created, updated time.Time + err = a.db.QueryRowContext(r.Context(), `SELECT po.user_id,u.public_id,p.nickname,p.avatar_url,p.gender,p.city_name,p.is_vip,po.content,po.visibility,po.city_code,po.location_text,po.status,po.moderation_status,po.like_count,po.comment_count,po.created_at,po.updated_at FROM posts po JOIN users u ON u.id=po.user_id JOIN user_profiles p ON p.user_id=po.user_id WHERE po.id=? AND po.deleted_at IS NULL`, id).Scan(&userID, &publicID, &nickname, &avatar, &gender, &cityName, &vip, &content, &visibility, &cityCode, &location, &status, &moderation, &likes, &comments, &created, &updated) + if err == sql.ErrNoRows { + fail(w, 404, 20004, "动态不存在或已下架") + return + } + if err != nil { + fail(w, 500, 50001, "查询动态详情失败") + return + } + + media := []map[string]any{} + mediaRows, mediaErr := a.db.QueryContext(r.Context(), `SELECT id,media_url,media_type,sort_order FROM post_media WHERE post_id=? ORDER BY sort_order,id`, id) + if mediaErr == nil { + defer mediaRows.Close() + for mediaRows.Next() { + var mediaID int64 + var mediaURL, mediaType string + var sortOrder int + if mediaRows.Scan(&mediaID, &mediaURL, &mediaType, &sortOrder) == nil { + media = append(media, map[string]any{"id": mediaID, "url": mediaURL, "type": mediaType, "sortOrder": sortOrder}) + } + } + } + + commentItems := []map[string]any{} + commentRows, commentErr := a.db.QueryContext(r.Context(), `SELECT c.id,c.user_id,p.nickname,p.avatar_url,c.parent_comment_id,c.reply_user_id,c.content,c.status,c.moderation_status,c.created_at FROM post_comments c JOIN user_profiles p ON p.user_id=c.user_id WHERE c.post_id=? AND c.deleted_at IS NULL ORDER BY c.created_at DESC LIMIT 100`, id) + if commentErr == nil { + defer commentRows.Close() + for commentRows.Next() { + var commentID, commentUserID int64 + var commentNickname, commentAvatar, commentContent string + var parentID, replyUserID sql.NullInt64 + var commentStatus, commentModeration int + var commentCreated time.Time + if commentRows.Scan(&commentID, &commentUserID, &commentNickname, &commentAvatar, &parentID, &replyUserID, &commentContent, &commentStatus, &commentModeration, &commentCreated) == nil { + commentItems = append(commentItems, map[string]any{"id": commentID, "userId": commentUserID, "nickname": commentNickname, "avatar": commentAvatar, "parentCommentId": nullableID(parentID), "replyUserId": nullableID(replyUserID), "content": commentContent, "status": commentStatus, "moderationStatus": commentModeration, "createdAt": commentCreated}) + } + } + } + + likeItems := []map[string]any{} + likeRows, likeErr := a.db.QueryContext(r.Context(), `SELECT l.user_id,p.nickname,p.avatar_url,l.created_at FROM post_likes l JOIN user_profiles p ON p.user_id=l.user_id WHERE l.post_id=? ORDER BY l.created_at DESC LIMIT 100`, id) + if likeErr == nil { + defer likeRows.Close() + for likeRows.Next() { + var likeUserID int64 + var likeNickname, likeAvatar string + var likeCreated time.Time + if likeRows.Scan(&likeUserID, &likeNickname, &likeAvatar, &likeCreated) == nil { + likeItems = append(likeItems, map[string]any{"userId": likeUserID, "nickname": likeNickname, "avatar": likeAvatar, "createdAt": likeCreated}) + } + } + } + + var reportCount int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM reports WHERE target_type='post' AND target_id=?`, id).Scan(&reportCount) + reply(w, map[string]any{ + "id": id, "content": content, "visibility": visibility, "cityCode": cityCode, "location": location, + "status": status, "moderationStatus": moderation, "likeCount": likes, "commentCount": comments, + "reportCount": reportCount, "createdAt": created, "updatedAt": updated, "media": media, + "comments": commentItems, "likes": likeItems, + "user": map[string]any{"id": userID, "publicId": publicID, "nickname": nickname, "avatar": avatar, "gender": gender, "city": cityName, "vip": vip == 1}, + }) +} + +func nullableID(value sql.NullInt64) any { + if value.Valid { + return value.Int64 + } + return nil +} + +func (a *App) adminDeletePost(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE posts SET status=0,deleted_at=NOW(3) WHERE id=? AND status=1`, id) + if err != nil { + fail(w, 500, 50001, "删除失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 404, 30001, "动态不存在或已下架") + return + } + a.audit(r, "delete", "post", id, nil) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminReports(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + status := r.URL.Query().Get("status") + where := "" + args := []any{} + if status != "" { + where = ` WHERE r.status=?` + args = append(args, status) + } + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM reports r`+where, args...).Scan(&total) + args = append(args, size, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT r.id,r.reporter_user_id,p.nickname,r.target_type,r.target_id,r.reason_code,r.description,COALESCE(CAST(r.evidence_json AS CHAR CHARACTER SET utf8mb4),'[]'),r.status,r.action_type,r.handle_remark,r.handled_by,COALESCE(handler.real_name,''),r.handled_at,r.created_at FROM reports r JOIN user_profiles p ON p.user_id=r.reporter_user_id LEFT JOIN admin_users handler ON handler.id=r.handled_by`+where+` ORDER BY r.created_at DESC LIMIT ? OFFSET ?`, args...) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, reporter, target int64 + var nick, targetType, reason, description, evidenceJSON, status, actionType, handleRemark, handlerName string + var handler, handled any + var created time.Time + _ = rows.Scan(&id, &reporter, &nick, &targetType, &target, &reason, &description, &evidenceJSON, &status, &actionType, &handleRemark, &handler, &handlerName, &handled, &created) + evidence := []string{} + _ = json.Unmarshal([]byte(evidenceJSON), &evidence) + items = append(items, map[string]any{"id": id, "reporterId": reporter, "reporterName": nick, "targetType": targetType, "targetId": target, "reason": reason, "description": description, "evidence": evidence, "status": status, "actionType": actionType, "handleRemark": handleRemark, "handledBy": handler, "handlerName": handlerName, "handledAt": handled, "createdAt": created}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} +func (a *App) adminHandleReport(w http.ResponseWriter, r *http.Request) { + id, pathErr := pathID(r) + if pathErr != nil { + fail(w, 400, 20001, "举报编号无效") + return + } + var req struct { + Result string `json:"result"` + Remark string `json:"remark"` + SanctionType string `json:"sanctionType"` + DurationDays int `json:"durationDays"` + } + if decode(r, &req) != nil || (req.Result != "punished" && req.Result != "dismissed") { + fail(w, 400, 20001, "处理结果无效") + return + } + status := "REJECTED" + actionType := "NONE" + if req.Result == "punished" { + status = "PUNISHED" + } + var targetType string + var targetID, reporterID int64 + if a.db.QueryRowContext(r.Context(), `SELECT target_type,target_id,reporter_user_id FROM reports WHERE id=? AND status='PENDING'`, id).Scan(&targetType, &targetID, &reporterID) != nil { + fail(w, 404, 30001, "待处理举报不存在") + return + } + if status == "PUNISHED" { + if len([]rune(strings.TrimSpace(req.Remark))) < 2 { + fail(w, 400, 20001, "请填写明确的处理原因") + return + } + if targetType == "user" { + typ := strings.ToUpper(strings.TrimSpace(req.SanctionType)) + if typ == "" { + typ = "WARNING" + } + actionType = typ + if !map[string]bool{"WARNING": true, "FREEZE": true, "BAN": true, "MUTE": true, "CONTENT_LIMIT": true}[typ] { + fail(w, 400, 20001, "处罚类型无效") + return + } + if typ != "WARNING" && typ != "BAN" && (req.DurationDays < 1 || req.DurationDays > 3650) { + fail(w, 400, 20001, "临时处罚期限应为 1-3650 天") + return + } + var expiry *time.Time + if typ != "WARNING" && typ != "BAN" { + value := time.Now().AddDate(0, 0, req.DurationDays) + expiry = &value + } + if _, err := a.createSanction(r.Context(), current(r).ID, targetID, typ, "举报处理:"+strings.TrimSpace(req.Remark), expiry); err != nil { + fail(w, 500, 50001, "处罚执行失败") + return + } + } else if targetType == "post" { + actionType = "CONTENT_REMOVE" + result, actionErr := a.db.ExecContext(r.Context(), `UPDATE posts SET status=0,deleted_at=COALESCE(deleted_at,NOW(3)) WHERE id=? AND status=1`, targetID) + var affected int64 + if actionErr == nil { + affected, _ = result.RowsAffected() + } + if actionErr != nil || affected == 0 { + fail(w, 409, 20001, "动态已下架或不存在") + return + } + } else if targetType == "comment" { + actionType = "CONTENT_REMOVE" + tx, beginErr := a.db.BeginTx(r.Context(), nil) + if beginErr != nil { + fail(w, 500, 50001, "评论处置失败") + return + } + var postID int64 + if beginErr = tx.QueryRowContext(r.Context(), `SELECT post_id FROM post_comments WHERE id=? AND status=1 FOR UPDATE`, targetID).Scan(&postID); beginErr == nil { + _, beginErr = tx.ExecContext(r.Context(), `UPDATE post_comments SET status=0,deleted_at=COALESCE(deleted_at,NOW(3)) WHERE id=?`, targetID) + } + if beginErr == nil { + _, beginErr = tx.ExecContext(r.Context(), `UPDATE posts SET comment_count=GREATEST(comment_count-1,0) WHERE id=?`, postID) + } + if beginErr != nil || tx.Commit() != nil { + _ = tx.Rollback() + fail(w, 409, 20001, "评论已删除或不存在") + return + } + } else if targetType == "message" { + actionType = "CONTENT_REMOVE" + var conversationID, seq int64 + if a.db.QueryRowContext(r.Context(), `SELECT conversation_id,seq FROM im_messages WHERE id=?`, targetID).Scan(&conversationID, &seq) != nil { + fail(w, 404, 30001, "消息不存在") + return + } + if _, actionErr := a.db.ExecContext(r.Context(), `UPDATE im_messages SET moderation_status=0,admin_removed_at=NOW(3),admin_removed_by=?,admin_remove_reason=? WHERE id=?`, current(r).ID, strings.TrimSpace(req.Remark), targetID); actionErr != nil { + fail(w, 500, 50001, "消息处置失败") + return + } + rows, _ := a.db.QueryContext(r.Context(), `SELECT user_id FROM im_conversation_members WHERE conversation_id=? AND status=1`, conversationID) + members := []int64{} + if rows != nil { + for rows.Next() { + var memberID int64 + _ = rows.Scan(&memberID) + members = append(members, memberID) + } + _ = rows.Close() + } + a.hub.broadcast(members, map[string]any{"command": "MESSAGE_RECALLED", "data": map[string]any{"id": targetID, "conversationId": conversationID, "seq": seq}}) + } else { + fail(w, 400, 20001, "举报目标类型无效") + return + } + } + result, err := a.db.ExecContext(r.Context(), `UPDATE reports SET status=?,action_type=?,handle_remark=?,handled_by=?,handled_at=NOW(3) WHERE id=? AND status='PENDING'`, status, actionType, strings.TrimSpace(req.Remark), current(r).ID, id) + var affected int64 + if err == nil { + affected, _ = result.RowsAffected() + } + if err != nil || affected == 0 { + fail(w, 500, 50001, "处理失败") + return + } + a.audit(r, "handle", "report", id, req) + content := "举报已处理,感谢你帮助维护社区环境。" + if status == "REJECTED" { + content = "本次举报经核查暂未发现违规,感谢你的反馈。" + } + a.notifyUser(r.Context(), reporterID, "system", "举报处理结果", content, "report", id) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminRiskUsers(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_risk_profiles`).Scan(&total) + rows, err := a.db.QueryContext(r.Context(), `SELECT rp.user_id,p.nickname,p.avatar_url,rp.risk_score,rp.risk_level,rp.message_score,rp.device_score,rp.report_score,rp.behavior_score,rp.updated_at FROM user_risk_profiles rp JOIN user_profiles p ON p.user_id=rp.user_id ORDER BY rp.risk_score DESC LIMIT ? OFFSET ?`, size, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var nick, avatar string + var score, level, message, device, report, behavior int + var updated time.Time + _ = rows.Scan(&id, &nick, &avatar, &score, &level, &message, &device, &report, &behavior, &updated) + items = append(items, map[string]any{"userId": id, "nickname": nick, "avatar": avatar, "riskScore": score, "riskLevel": level, "messageScore": message, "deviceScore": device, "reportScore": report, "behaviorScore": behavior, "updatedAt": updated}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} +func (a *App) adminRiskEvents(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM risk_events`).Scan(&total) + rows, err := a.db.QueryContext(r.Context(), `SELECT e.id,e.user_id,p.nickname,e.event_type,e.score_delta,e.device_id,e.ip,e.metadata,e.created_at FROM risk_events e JOIN user_profiles p ON p.user_id=e.user_id ORDER BY e.created_at DESC LIMIT ? OFFSET ?`, size, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, userID int64 + var nick, typ, device, ip string + var delta int + var metadata []byte + var created time.Time + _ = rows.Scan(&id, &userID, &nick, &typ, &delta, &device, &ip, &metadata, &created) + var meta any + _ = json.Unmarshal(metadata, &meta) + items = append(items, map[string]any{"id": id, "userId": userID, "nickname": nick, "eventType": typ, "scoreDelta": delta, "deviceId": device, "ip": ip, "metadata": meta, "createdAt": created}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} + +func (a *App) adminPlans(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,code,name,level,duration_days,daily_active_chat_limit,daily_like_limit,can_view_visitors,can_invisible_visit,recommendation_weight,price_cent,original_price_cent,status,sort_order FROM membership_plans WHERE deleted_at IS NULL ORDER BY sort_order,id`) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []planView{} + for rows.Next() { + var item planView + _ = rows.Scan(&item.ID, &item.Code, &item.Name, &item.Level, &item.DurationDays, &item.DailyActiveChatLimit, &item.DailyLikeLimit, &item.CanViewVisitors, &item.CanInvisibleVisit, &item.RecommendationWeight, &item.PriceCent, &item.OriginalPriceCent, &item.Status, &item.SortOrder) + items = append(items, item) + } + reply(w, map[string]any{"items": items, "total": len(items)}) +} +func (a *App) adminCreatePlan(w http.ResponseWriter, r *http.Request) { + var req planView + if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" || strings.TrimSpace(req.Name) == "" || req.Level < 1 || req.Level > 20 || req.DurationDays < 1 || req.DailyActiveChatLimit < 0 || req.DailyActiveChatLimit > 10000 || req.DailyLikeLimit < 0 || req.DailyLikeLimit > 100000 || req.RecommendationWeight < 0 || req.RecommendationWeight > 10000 || req.PriceCent < 0 || req.OriginalPriceCent < req.PriceCent { + fail(w, 400, 20001, "请完整填写套餐信息,原价不能低于售价") + return + } + req.Code = strings.ToUpper(strings.TrimSpace(req.Code)) + req.Name = strings.TrimSpace(req.Name) + result, err := a.db.ExecContext(r.Context(), `INSERT INTO membership_plans(code,name,level,duration_days,daily_active_chat_limit,daily_like_limit,can_view_visitors,can_invisible_visit,recommendation_weight,price_cent,original_price_cent,status,sort_order)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, req.Code, req.Name, req.Level, req.DurationDays, req.DailyActiveChatLimit, req.DailyLikeLimit, req.CanViewVisitors, req.CanInvisibleVisit, req.RecommendationWeight, req.PriceCent, req.OriginalPriceCent, req.Status, req.SortOrder) + if err != nil { + fail(w, http.StatusConflict, 20001, "套餐编码已存在或数据无效") + return + } + id, _ := result.LastInsertId() + a.audit(r, "create", "membership_plan", id, req) + reply(w, map[string]any{"id": id}) +} +func (a *App) adminUpdatePlan(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + var req planView + if err != nil || decode(r, &req) != nil || strings.TrimSpace(req.Name) == "" || req.Level < 1 || req.Level > 20 || req.DurationDays < 1 || req.DailyActiveChatLimit < 0 || req.DailyActiveChatLimit > 10000 || req.DailyLikeLimit < 0 || req.DailyLikeLimit > 100000 || req.RecommendationWeight < 0 || req.RecommendationWeight > 10000 || req.PriceCent < 0 || req.OriginalPriceCent < req.PriceCent { + fail(w, 400, 20001, "套餐信息不完整或价格无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE membership_plans SET name=?,level=?,duration_days=?,daily_active_chat_limit=?,daily_like_limit=?,can_view_visitors=?,can_invisible_visit=?,recommendation_weight=?,price_cent=?,original_price_cent=?,status=?,sort_order=? WHERE id=? AND deleted_at IS NULL`, strings.TrimSpace(req.Name), req.Level, req.DurationDays, req.DailyActiveChatLimit, req.DailyLikeLimit, req.CanViewVisitors, req.CanInvisibleVisit, req.RecommendationWeight, req.PriceCent, req.OriginalPriceCent, req.Status, req.SortOrder, id) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 404, 30001, "套餐不存在或已删除") + return + } + a.audit(r, "update", "membership_plan", id, req) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminOrders(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + status := strings.TrimSpace(r.URL.Query().Get("status")) + keyword := strings.TrimSpace(r.URL.Query().Get("keyword")) + where := ` WHERE o.deleted_at IS NULL` + args := []any{} + if status != "" { + where += ` AND o.status=?` + args = append(args, status) + } + if keyword != "" { + where += ` AND (o.order_no LIKE ? OR p.nickname LIKE ? OR u.public_id LIKE ?)` + like := "%" + keyword + "%" + args = append(args, like, like, like) + } + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM orders o JOIN users u ON u.id=o.user_id JOIN user_profiles p ON p.user_id=o.user_id`+where, args...).Scan(&total) + args = append(args, size, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT o.id,o.order_no,o.user_id,u.public_id,p.nickname,o.product_id,COALESCE(mp.name,''),o.amount_cent,o.currency,o.status,o.channel,o.paid_at,o.created_at FROM orders o JOIN users u ON u.id=o.user_id JOIN user_profiles p ON p.user_id=o.user_id LEFT JOIN membership_plans mp ON mp.id=o.product_id`+where+` ORDER BY o.created_at DESC LIMIT ? OFFSET ?`, args...) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, userID, productID int64 + var orderNo, publicID, nickname, product, currency, orderStatus, channel string + var amount int + var paid any + var created time.Time + _ = rows.Scan(&id, &orderNo, &userID, &publicID, &nickname, &productID, &product, &amount, ¤cy, &orderStatus, &channel, &paid, &created) + items = append(items, map[string]any{"id": id, "orderNo": orderNo, "userId": userID, "publicId": publicID, "nickname": nickname, "productId": productID, "productName": product, "amountCent": amount, "currency": currency, "status": orderStatus, "channel": channel, "paidAt": paid, "createdAt": created}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} +func (a *App) adminRefund(w http.ResponseWriter, r *http.Request) { + if a.configPlain(r.Context(), "payment.mode", "sandbox") == "live" { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "订单编号无效") + return + } + a.requestLiveRefund(w, r, id) + return + } + a.adminOrderTransition(w, r, "refund") +} + +func (a *App) adminConfigs(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT config_key,config_value,value_type,description,updated_at FROM system_configs WHERE config_key NOT LIKE 'sms.%' AND config_key NOT LIKE 'payment.%' AND config_key NOT LIKE 'storage.%' AND config_key NOT LIKE 'oauth.%' ORDER BY config_key`) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var key, value, typ, description string + var updated time.Time + _ = rows.Scan(&key, &value, &typ, &description, &updated) + if typ == "secret" && value != "" { + value = maskedSecret + } + items = append(items, map[string]any{"key": key, "value": value, "type": typ, "description": description, "updatedAt": updated}) + } + reply(w, map[string]any{"items": items}) +} +func (a *App) adminUpdateConfig(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + key, _ := url.PathUnescape(parts[len(parts)-1]) + var req struct { + Value string `json:"value"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "invalid value") + return + } + var valueType string + if err := a.db.QueryRowContext(r.Context(), `SELECT value_type FROM system_configs WHERE config_key=?`, key).Scan(&valueType); err != nil { + fail(w, 404, 30001, "配置项不存在") + return + } + if valueType == "secret" { + if req.Value == maskedSecret || req.Value == "" { + reply(w, map[string]bool{"success": true}) + return + } + encrypted, err := a.encryptSecret(req.Value) + if err != nil { + fail(w, 500, 50001, "加密敏感配置失败") + return + } + req.Value = encrypted + } + _, err := a.db.ExecContext(r.Context(), `UPDATE system_configs SET config_value=? WHERE config_key=?`, req.Value, key) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + a.audit(r, "update", "system_config", 0, map[string]any{"key": key, "secret": valueType == "secret"}) + reply(w, map[string]bool{"success": true}) +} +func (a *App) adminAuditLogs(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM admin_audit_logs`).Scan(&total) + rows, err := a.db.QueryContext(r.Context(), `SELECT l.id,l.admin_user_id,a.real_name,l.action,l.target_type,l.target_id,l.request_data,l.ip,l.created_at FROM admin_audit_logs l JOIN admin_users a ON a.id=l.admin_user_id ORDER BY l.created_at DESC LIMIT ? OFFSET ?`, size, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, adminID int64 + var name, action, targetType, ip string + var targetID any + var data []byte + var created time.Time + _ = rows.Scan(&id, &adminID, &name, &action, &targetType, &targetID, &data, &ip, &created) + var request any + _ = json.Unmarshal(data, &request) + items = append(items, map[string]any{"id": id, "adminId": adminID, "adminName": name, "action": action, "targetType": targetType, "targetId": targetID, "request": request, "ip": ip, "createdAt": created}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} + +func (a *App) audit(r *http.Request, action, targetType string, targetID int64, data any) { + payload, _ := json.Marshal(data) + var id any + if targetID > 0 { + id = targetID + } + _, _ = a.db.ExecContext(r.Context(), `INSERT INTO admin_audit_logs(admin_user_id,action,target_type,target_id,request_data,ip)VALUES(?,?,?,?,?,?)`, current(r).ID, action, targetType, id, payload, clientIP(r)) +} +func maskPhone(value string) string { + if len(value) >= 7 { + return value[:3] + "****" + value[len(value)-4:] + } + return value +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_client_operations.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_client_operations.go new file mode 100644 index 0000000..d11ac38 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_client_operations.go @@ -0,0 +1,247 @@ +package app + +import ( + "database/sql" + "encoding/json" + "net/http" + "strconv" + "strings" + "time" +) + +func decodeStringArray(value string) []string { + items := []string{} + _ = json.Unmarshal([]byte(value), &items) + return items +} + +func (a *App) adminFeedback(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + page, size, offset := pagination(r) + status := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("status"))) + where, args := "", []any{} + if status != "" { + where = " WHERE f.status=?" + args = append(args, status) + } + var total int64 + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_feedback f`+where, args...).Scan(&total); err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询反馈失败") + return + } + queryArgs := append(append([]any{}, args...), size, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.user_id,u.public_id,p.nickname,p.avatar_url,f.category,f.content,f.contact,COALESCE(f.evidence_json,'[]'),f.status,f.reply_content,f.handled_by,f.handled_at,f.created_at,f.updated_at FROM user_feedback f JOIN users u ON u.id=f.user_id JOIN user_profiles p ON p.user_id=f.user_id`+where+` ORDER BY f.created_at DESC LIMIT ? OFFSET ?`, queryArgs...) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询反馈失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, userID int64 + var publicID, nickname, avatar, category, content, contact, evidence, itemStatus, replyContent string + var handledBy sql.NullInt64 + var handledAt sql.NullTime + var createdAt, updatedAt time.Time + if err = rows.Scan(&id, &userID, &publicID, &nickname, &avatar, &category, &content, &contact, &evidence, &itemStatus, &replyContent, &handledBy, &handledAt, &createdAt, &updatedAt); err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取反馈失败") + return + } + items = append(items, map[string]any{"id": id, "userId": userID, "publicId": publicID, "nickname": nickname, "avatar": avatar, "category": category, "content": content, "contact": contact, "evidence": decodeStringArray(evidence), "status": itemStatus, "replyContent": replyContent, "handledBy": nullableInt64(handledBy), "handledAt": nullableTime(handledAt), "createdAt": createdAt, "updatedAt": updatedAt}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) + return + } + id, err := pathID(r) + var req struct { + Status string `json:"status"` + Reply string `json:"reply"` + } + if err != nil || decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "反馈参数无效") + return + } + req.Status = strings.ToUpper(strings.TrimSpace(req.Status)) + if req.Status != "PROCESSING" && req.Status != "RESOLVED" && req.Status != "CLOSED" { + fail(w, http.StatusBadRequest, 20001, "反馈状态无效") + return + } + if req.Status == "RESOLVED" && strings.TrimSpace(req.Reply) == "" { + fail(w, http.StatusBadRequest, 20001, "解决反馈时必须填写回复") + return + } + var userID int64 + if err = a.db.QueryRowContext(r.Context(), `SELECT user_id FROM user_feedback WHERE id=?`, id).Scan(&userID); err != nil { + fail(w, http.StatusNotFound, 30001, "反馈不存在") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE user_feedback SET status=?,reply_content=?,handled_by=?,handled_at=NOW(3) WHERE id=?`, req.Status, strings.TrimSpace(req.Reply), current(r).ID, id) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新反馈失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusNotFound, 30001, "反馈不存在") + return + } + if strings.TrimSpace(req.Reply) != "" { + a.notifyUser(r.Context(), userID, "system", "反馈处理结果", strings.TrimSpace(req.Reply), "feedback", id) + } + a.audit(r, "handle_feedback", "feedback", id, map[string]any{"status": req.Status}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminAccountClosures(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + page, size, offset := pagination(r) + status := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("status"))) + where, args := "", []any{} + if status != "" { + where = " WHERE c.status=?" + args = append(args, status) + } + var total int64 + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_account_closures c`+where, args...).Scan(&total); err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询注销申请失败") + return + } + queryArgs := append(append([]any{}, args...), size, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT c.user_id,u.public_id,p.nickname,p.avatar_url,c.reason,c.status,c.requested_at,c.execute_after,c.cancelled_at,c.completed_at FROM user_account_closures c JOIN users u ON u.id=c.user_id JOIN user_profiles p ON p.user_id=c.user_id`+where+` ORDER BY c.requested_at DESC LIMIT ? OFFSET ?`, queryArgs...) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询注销申请失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var userID int64 + var publicID, nickname, avatar, reason, itemStatus string + var requestedAt, executeAfter time.Time + var cancelledAt, completedAt sql.NullTime + if err = rows.Scan(&userID, &publicID, &nickname, &avatar, &reason, &itemStatus, &requestedAt, &executeAfter, &cancelledAt, &completedAt); err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取注销申请失败") + return + } + items = append(items, map[string]any{"userId": userID, "publicId": publicID, "nickname": nickname, "avatar": avatar, "reason": reason, "status": itemStatus, "requestedAt": requestedAt, "executeAfter": executeAfter, "cancelledAt": nullableTime(cancelledAt), "completedAt": nullableTime(completedAt)}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) + return + } + userID, err := pathID(r) + var req struct { + Action string `json:"action"` + Reason string `json:"reason"` + } + if err != nil || decode(r, &req) != nil || strings.ToLower(req.Action) != "cancel" || strings.TrimSpace(req.Reason) == "" { + fail(w, http.StatusBadRequest, 20001, "必须填写取消注销的原因") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE user_account_closures SET status='CANCELLED',cancelled_at=NOW(3) WHERE user_id=? AND status='PENDING'`, userID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "取消注销申请失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusBadRequest, 20001, "只有待执行的注销申请可以取消") + return + } + a.notifyUser(r.Context(), userID, "system", "注销申请已取消", strings.TrimSpace(req.Reason), "account_closure", nil) + a.audit(r, "cancel_account_closure", "user", userID, map[string]any{"reason": req.Reason}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminAppVersions(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,platform,version,build_number,force_update,download_url,release_notes,status,created_at FROM app_versions ORDER BY platform,build_number DESC`) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询客户端版本失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var platform, version, downloadURL, notes string + var build, force, status int + var createdAt time.Time + if rows.Scan(&id, &platform, &version, &build, &force, &downloadURL, ¬es, &status, &createdAt) == nil { + items = append(items, map[string]any{"id": id, "platform": platform, "version": version, "buildNumber": build, "forceUpdate": force == 1, "downloadUrl": downloadURL, "releaseNotes": notes, "status": status, "createdAt": createdAt}) + } + } + reply(w, map[string]any{"items": items, "total": len(items)}) + return + } + if r.Method == http.MethodDelete { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "版本编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `DELETE FROM app_versions WHERE id=?`, id) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "删除版本失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusNotFound, 30001, "版本不存在") + return + } + a.audit(r, "delete_app_version", "app_version", id, nil) + reply(w, map[string]bool{"success": true}) + return + } + var req struct { + ID int64 `json:"id"` + Platform string `json:"platform"` + Version string `json:"version"` + BuildNumber int `json:"buildNumber"` + ForceUpdate bool `json:"forceUpdate"` + DownloadURL string `json:"downloadUrl"` + ReleaseNotes string `json:"releaseNotes"` + Status int `json:"status"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "版本参数无效") + return + } + req.Platform = strings.ToLower(strings.TrimSpace(req.Platform)) + if req.Platform != "android" && req.Platform != "ios" && req.Platform != "h5" || strings.TrimSpace(req.Version) == "" || req.BuildNumber <= 0 || req.Status < 0 || req.Status > 1 { + fail(w, http.StatusBadRequest, 20001, "平台、版本号或构建号无效") + return + } + force := 0 + if req.ForceUpdate { + force = 1 + } + if r.Method == http.MethodPost { + result, err := a.db.ExecContext(r.Context(), `INSERT INTO app_versions(platform,version,build_number,force_update,download_url,release_notes,status) VALUES(?,?,?,?,?,?,?)`, req.Platform, strings.TrimSpace(req.Version), req.BuildNumber, force, strings.TrimSpace(req.DownloadURL), strings.TrimSpace(req.ReleaseNotes), req.Status) + if err != nil { + fail(w, http.StatusConflict, 20001, "该平台构建号已存在") + return + } + id, _ := result.LastInsertId() + a.audit(r, "create_app_version", "app_version", id, map[string]any{"platform": req.Platform, "version": req.Version, "buildNumber": req.BuildNumber}) + reply(w, map[string]any{"id": id}) + return + } + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "版本编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE app_versions SET platform=?,version=?,build_number=?,force_update=?,download_url=?,release_notes=?,status=? WHERE id=?`, req.Platform, strings.TrimSpace(req.Version), req.BuildNumber, force, strings.TrimSpace(req.DownloadURL), strings.TrimSpace(req.ReleaseNotes), req.Status, id) + if err != nil { + fail(w, http.StatusConflict, 20001, "该平台构建号已存在") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusNotFound, 30001, "版本不存在") + return + } + a.audit(r, "update_app_version", "app_version", id, map[string]any{"platform": req.Platform, "version": req.Version, "buildNumber": req.BuildNumber, "status": strconv.Itoa(req.Status)}) + reply(w, map[string]bool{"success": true}) +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_commerce_messages.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_commerce_messages.go new file mode 100644 index 0000000..1321f21 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_commerce_messages.go @@ -0,0 +1,284 @@ +package app + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +func (a *App) adminDeletePlan(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "套餐编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE membership_plans SET status=0,deleted_at=NOW(3) WHERE id=? AND deleted_at IS NULL`, id) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "删除套餐失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusNotFound, 30001, "套餐不存在或已删除") + return + } + var subscriptions, orders int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM subscriptions WHERE plan_id=?`, id).Scan(&subscriptions) + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM orders WHERE product_id=?`, id).Scan(&orders) + a.audit(r, "delete", "membership_plan", id, map[string]any{"mode": "soft", "subscriptions": subscriptions, "orders": orders}) + reply(w, map[string]any{"success": true, "archivedSubscriptions": subscriptions, "archivedOrders": orders}) +} + +type adminOrderUpdateRequest struct { + AmountCent *int `json:"amountCent"` + Channel *string `json:"channel"` + ProductID *int64 `json:"productId"` + Status *string `json:"status"` +} + +func validOrderStatus(status string) bool { + return status == "CREATED" || status == "PAID" || status == "REFUND_REQUESTED" || status == "REFUNDING" || status == "REFUNDED" || status == "CLOSED" +} + +func orderEntitlementActive(status string) bool { + return status == "PAID" || status == "REFUND_REQUESTED" || status == "REFUNDING" +} + +func (a *App) syncEditedOrderEntitlement(r *http.Request, tx *sql.Tx, orderID, userID, oldPlanID, newPlanID int64, oldStatus, newStatus string) error { + needsRevoke := orderEntitlementActive(oldStatus) && (!orderEntitlementActive(newStatus) || oldPlanID != newPlanID) + needsGrant := orderEntitlementActive(newStatus) && (!orderEntitlementActive(oldStatus) || oldPlanID != newPlanID) + if needsRevoke { + if _, err := tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1 AND source IN (?,?)`, userID, fmt.Sprintf("order:%d", orderID), fmt.Sprintf("admin_order:%d", orderID)); err != nil { + return err + } + } + if needsGrant { + var durationDays int + if err := tx.QueryRowContext(r.Context(), `SELECT duration_days FROM membership_plans WHERE id=?`, newPlanID).Scan(&durationDays); err != nil { + return err + } + if _, err := tx.ExecContext(r.Context(), `INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(?,?,?,1,NOW(3),DATE_ADD(NOW(3),INTERVAL ? DAY))`, userID, newPlanID, fmt.Sprintf("admin_order:%d", orderID), durationDays); err != nil { + return err + } + } + if needsRevoke || needsGrant { + return a.recomputeMembershipTx(r.Context(), tx, userID) + } + return nil +} + +func (a *App) adminUpdateOrder(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + var req adminOrderUpdateRequest + if err != nil || decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "订单信息格式无效") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存订单失败") + return + } + defer func() { _ = tx.Rollback() }() + var userID, oldPlanID int64 + var oldAmount int + var oldStatus, oldChannel, providerOrderNo string + if err = tx.QueryRowContext(r.Context(), `SELECT user_id,product_id,amount_cent,status,channel,provider_order_no FROM orders WHERE id=? AND deleted_at IS NULL FOR UPDATE`, id).Scan(&userID, &oldPlanID, &oldAmount, &oldStatus, &oldChannel, &providerOrderNo); err != nil { + fail(w, http.StatusNotFound, 30001, "订单不存在或已删除") + return + } + newPlanID, newAmount, newStatus, newChannel := oldPlanID, oldAmount, oldStatus, oldChannel + if req.ProductID != nil { + newPlanID = *req.ProductID + } + if req.AmountCent != nil { + newAmount = *req.AmountCent + } + if req.Status != nil { + newStatus = strings.ToUpper(strings.TrimSpace(*req.Status)) + } + if req.Channel != nil { + newChannel = strings.TrimSpace(*req.Channel) + } + if newPlanID <= 0 || newAmount < 0 || !validOrderStatus(newStatus) || len(newChannel) > 30 { + fail(w, http.StatusBadRequest, 20001, "订单套餐、金额、渠道或状态无效") + return + } + if a.configPlain(r.Context(), "payment.mode", "sandbox") == "live" { + financialFieldsChanged := newPlanID != oldPlanID || newAmount != oldAmount || newChannel != oldChannel + if financialFieldsChanged && (oldStatus != "CREATED" || providerOrderNo != "") { + fail(w, http.StatusBadRequest, 20001, "生产订单创建支付流水后禁止修改套餐、金额或渠道") + return + } + if newStatus != oldStatus && !(oldStatus == "CREATED" && newStatus == "CLOSED") { + fail(w, http.StatusBadRequest, 20001, "生产订单的支付与退款状态只能由已验签回调更新") + return + } + } + var planExists int + planQuery := `SELECT COUNT(*) FROM membership_plans WHERE id=?` + if newStatus == "PAID" || newPlanID != oldPlanID { + planQuery += ` AND deleted_at IS NULL` + } + if err = tx.QueryRowContext(r.Context(), planQuery, newPlanID).Scan(&planExists); err != nil || planExists == 0 { + fail(w, http.StatusBadRequest, 20001, "选择的会员套餐不存在") + return + } + if err = a.syncEditedOrderEntitlement(r, tx, id, userID, oldPlanID, newPlanID, oldStatus, newStatus); err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE orders SET product_id=?,amount_cent=?,status=?,channel=?,paid_at=IF(?='PAID',COALESCE(paid_at,NOW(3)),paid_at) WHERE id=?`, newPlanID, newAmount, newStatus, newChannel, newStatus, id) + } + if err != nil || tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "保存订单及会员权益失败") + return + } + a.audit(r, "update", "order", id, map[string]any{"previousStatus": oldStatus, "status": newStatus, "previousPlanId": oldPlanID, "planId": newPlanID, "amountCent": newAmount, "channel": newChannel}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminDeleteOrder(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "订单编号无效") + return + } + var userID int64 + var status string + if err = a.db.QueryRowContext(r.Context(), `SELECT user_id,status FROM orders WHERE id=? AND deleted_at IS NULL`, id).Scan(&userID, &status); err != nil { + fail(w, http.StatusNotFound, 30001, "订单不存在或已删除") + return + } + if status == "REFUNDING" { + fail(w, http.StatusBadRequest, 20001, "退款处理中的订单不能删除") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE orders SET deleted_at=NOW(3) WHERE id=? AND deleted_at IS NULL`, id) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "删除订单失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusNotFound, 30001, "订单不存在或已删除") + return + } + a.audit(r, "delete", "order", id, map[string]any{"mode": "soft", "status": status, "userId": userID, "membershipPreserved": status == "PAID"}) + reply(w, map[string]any{"success": true, "membershipPreserved": status == "PAID"}) +} + +func (a *App) adminMessages(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + keyword := strings.TrimSpace(r.URL.Query().Get("keyword")) + conversationID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("conversationId")), 10, 64) + messageType, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("type"))) + where := ` WHERE 1=1` + args := []any{} + if conversationID > 0 { + where += ` AND m.conversation_id=?` + args = append(args, conversationID) + } + if messageType > 0 { + where += ` AND m.message_type=?` + args = append(args, messageType) + } + if keyword != "" { + where += ` AND (CONVERT(m.client_msg_id USING utf8mb4) LIKE ? OR CAST(m.body AS CHAR CHARACTER SET utf8mb4) LIKE ? OR sp.nickname LIKE ? OR su.public_id LIKE ? OR EXISTS (SELECT 1 FROM im_conversation_members kcm JOIN users ku ON ku.id=kcm.user_id JOIN user_profiles kp ON kp.user_id=kcm.user_id WHERE kcm.conversation_id=m.conversation_id AND kcm.user_id<>m.sender_id AND (kp.nickname LIKE ? OR ku.public_id LIKE ?)))` + like := "%" + keyword + "%" + args = append(args, like, like, like, like, like, like) + } + base := ` FROM im_messages m JOIN users su ON su.id=m.sender_id JOIN user_profiles sp ON sp.user_id=m.sender_id` + var total int64 + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*)`+base+where, args...).Scan(&total); err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询消息记录失败") + return + } + queryArgs := append(append([]any{}, args...), size, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,m.conversation_id,m.seq,m.sender_id,su.public_id,sp.nickname,sp.avatar_url,m.client_msg_id,m.message_type,m.body,m.moderation_status,m.recalled_at,m.admin_removed_at,m.admin_removed_by,m.admin_remove_reason,m.created_at,COALESCE((SELECT GROUP_CONCAT(CONCAT(kp.nickname,' (',ku.public_id,')') ORDER BY kp.nickname SEPARATOR '、') FROM im_conversation_members kcm JOIN users ku ON ku.id=kcm.user_id JOIN user_profiles kp ON kp.user_id=kcm.user_id WHERE kcm.conversation_id=m.conversation_id AND kcm.user_id<>m.sender_id),'')`+base+where+` ORDER BY m.created_at DESC,m.id DESC LIMIT ? OFFSET ?`, queryArgs...) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询消息记录失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, convID, seq, senderID int64 + var publicID, nickname, avatar, clientMsgID, recipients, adminRemoveReason string + var typ, moderation int + var body []byte + var recalledAt, adminRemovedAt sql.NullTime + var adminRemovedBy sql.NullInt64 + var createdAt time.Time + if rows.Scan(&id, &convID, &seq, &senderID, &publicID, &nickname, &avatar, &clientMsgID, &typ, &body, &moderation, &recalledAt, &adminRemovedAt, &adminRemovedBy, &adminRemoveReason, &createdAt, &recipients) != nil { + continue + } + var content any + if json.Unmarshal(body, &content) != nil { + content = string(body) + } + items = append(items, map[string]any{"id": id, "conversationId": convID, "seq": seq, "senderId": senderID, "senderPublicId": publicID, "senderNickname": nickname, "senderAvatar": avatar, "recipients": recipients, "clientMsgId": clientMsgID, "type": typ, "content": content, "moderationStatus": moderation, "recalledAt": nullableTime(recalledAt), "adminRemovedAt": nullableTime(adminRemovedAt), "adminRemovedBy": nullableInt64(adminRemovedBy), "adminRemoveReason": adminRemoveReason, "createdAt": createdAt}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} + +func (a *App) adminModerateMessage(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "消息编号无效") + return + } + var req struct { + Action string `json:"action"` + Reason string `json:"reason"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "消息处置参数无效") + return + } + req.Action = strings.ToUpper(strings.TrimSpace(req.Action)) + req.Reason = strings.TrimSpace(req.Reason) + if req.Action != "REMOVE" && req.Action != "RESTORE" { + fail(w, http.StatusBadRequest, 20001, "消息处置动作无效") + return + } + if req.Action == "REMOVE" && (len([]rune(req.Reason)) < 2 || len([]rune(req.Reason)) > 500) { + fail(w, http.StatusBadRequest, 20001, "请填写 2-500 字下架原因") + return + } + var conversationID, seq int64 + if err = a.db.QueryRowContext(r.Context(), `SELECT conversation_id,seq FROM im_messages WHERE id=?`, id).Scan(&conversationID, &seq); err != nil { + fail(w, http.StatusNotFound, 30001, "消息不存在") + return + } + var result sql.Result + command := "MESSAGE_RECALLED" + if req.Action == "REMOVE" { + result, err = a.db.ExecContext(r.Context(), `UPDATE im_messages SET moderation_status=0,admin_removed_at=NOW(3),admin_removed_by=?,admin_remove_reason=? WHERE id=? AND admin_removed_at IS NULL`, current(r).ID, req.Reason, id) + } else { + command = "MESSAGE_RESTORED" + result, err = a.db.ExecContext(r.Context(), `UPDATE im_messages SET moderation_status=1,admin_removed_at=NULL,admin_removed_by=NULL,admin_remove_reason='' WHERE id=? AND admin_removed_at IS NOT NULL`, id) + } + var affected int64 + if err == nil { + affected, _ = result.RowsAffected() + } + if err != nil || affected == 0 { + fail(w, http.StatusConflict, 20001, "消息状态已发生变化,请刷新后重试") + return + } + rows, _ := a.db.QueryContext(r.Context(), `SELECT user_id FROM im_conversation_members WHERE conversation_id=? AND status=1`, conversationID) + members := []int64{} + if rows != nil { + for rows.Next() { + var memberID int64 + _ = rows.Scan(&memberID) + members = append(members, memberID) + } + _ = rows.Close() + } + a.hub.broadcast(members, map[string]any{"command": command, "data": map[string]any{"id": id, "conversationId": conversationID, "seq": seq}}) + a.audit(r, strings.ToLower(req.Action)+"_message", "message", id, map[string]any{"reason": req.Reason}) + reply(w, map[string]bool{"success": true}) +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_oauth.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_oauth.go new file mode 100644 index 0000000..ab9d43a --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_oauth.go @@ -0,0 +1,754 @@ +package app + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + adminOAuthStateTTL = 10 * time.Minute + adminOAuthCodeTTL = 5 * time.Minute + adminOAuthBodyMax = 1 << 20 +) + +var adminOAuthProviderNames = map[string]string{ + "wechat": "微信", + "qq": "QQ", + "github": "GitHub", + "google": "Google", +} + +var adminOAuthAllowedHosts = map[string]map[string]bool{ + "wechat": {"open.weixin.qq.com": true, "api.weixin.qq.com": true}, + "qq": {"graph.qq.com": true}, + "github": {"github.com": true, "api.github.com": true}, + "google": {"accounts.google.com": true, "oauth2.googleapis.com": true, "openidconnect.googleapis.com": true}, +} + +type adminOAuthProvider struct { + Code string + Name string + ClientID string + ClientSecret string + AuthorizationURL string + TokenURL string + OpenIDURL string + UserInfoURL string + Scope string + RedirectURI string +} + +type adminOAuthIdentity struct { + Subject string + Email string + DisplayName string + AvatarURL string +} + +type adminOAuthLoginCode struct { + Provider string + Subject string + Email string + DisplayName string + AvatarURL string + AdminUserID sql.NullInt64 +} + +func oauthHash(value string) []byte { + hash := sha256.Sum256([]byte(value)) + return hash[:] +} + +func pkceChallenge(verifier string) string { + hash := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(hash[:]) +} + +func (a *App) adminOAuthProvider(ctx context.Context, code string) (adminOAuthProvider, error) { + name, ok := adminOAuthProviderNames[code] + if !ok { + return adminOAuthProvider{}, errors.New("不支持的第三方登录渠道") + } + prefix := "oauth." + code + "." + provider := adminOAuthProvider{ + Code: code, + Name: name, + ClientID: strings.TrimSpace(a.configPlain(ctx, prefix+"client_id", "")), + ClientSecret: strings.TrimSpace(a.configPlain(ctx, prefix+"client_secret", "")), + AuthorizationURL: strings.TrimSpace(a.configPlain(ctx, prefix+"authorization_url", "")), + TokenURL: strings.TrimSpace(a.configPlain(ctx, prefix+"token_url", "")), + OpenIDURL: strings.TrimSpace(a.configPlain(ctx, prefix+"openid_url", "")), + UserInfoURL: strings.TrimSpace(a.configPlain(ctx, prefix+"userinfo_url", "")), + Scope: strings.TrimSpace(a.configPlain(ctx, prefix+"scope", "")), + RedirectURI: strings.TrimSpace(a.configPlain(ctx, prefix+"redirect_uri", "")), + } + if provider.ClientID == "" || provider.ClientSecret == "" || provider.AuthorizationURL == "" || provider.TokenURL == "" || provider.UserInfoURL == "" || provider.Scope == "" || provider.RedirectURI == "" { + return adminOAuthProvider{}, fmt.Errorf("%s登录配置不完整", name) + } + if code == "qq" && provider.OpenIDURL == "" { + return adminOAuthProvider{}, errors.New("QQ 登录 OpenID 地址未配置") + } + for label, raw := range map[string]string{ + "授权地址": provider.AuthorizationURL, + "令牌地址": provider.TokenURL, + "用户信息地址": provider.UserInfoURL, + } { + if err := validateAdminOAuthEndpoint(code, raw); err != nil { + return adminOAuthProvider{}, fmt.Errorf("%s%s无效:%w", name, label, err) + } + } + if provider.OpenIDURL != "" { + if err := validateAdminOAuthEndpoint(code, provider.OpenIDURL); err != nil { + return adminOAuthProvider{}, fmt.Errorf("%s OpenID 地址无效:%w", name, err) + } + } + if err := a.validateAdminOAuthRedirectURL(provider.RedirectURI); err != nil { + return adminOAuthProvider{}, fmt.Errorf("%s回调地址无效:%w", name, err) + } + return provider, nil +} + +func validateAdminOAuthEndpoint(provider, raw string) error { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil { + return errors.New("必须是合法的 HTTPS 地址") + } + if !adminOAuthAllowedHosts[provider][strings.ToLower(parsed.Hostname())] { + return errors.New("域名不在该渠道的官方白名单内") + } + if port := parsed.Port(); port != "" && port != "443" { + return errors.New("仅允许使用标准 HTTPS 端口") + } + return nil +} + +func (a *App) validateAdminOAuthRedirectURL(raw string) error { + parsed, err := url.Parse(raw) + if err != nil || parsed.Hostname() == "" || parsed.User != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") { + return errors.New("必须是合法的 HTTP(S) 地址") + } + if parsed.Scheme == "http" { + host := strings.ToLower(parsed.Hostname()) + if a.config.Environment == "production" || (host != "localhost" && host != "127.0.0.1" && host != "::1") { + return errors.New("仅本地开发允许 HTTP,生产环境必须使用 HTTPS") + } + } + return nil +} + +func (a *App) adminOAuthFrontendURL(ctx context.Context) (string, error) { + raw := strings.TrimSpace(a.configPlain(ctx, "oauth.admin.frontend_callback_url", "")) + if raw == "" { + return "", errors.New("管理端登录结果页未配置") + } + if err := a.validateAdminOAuthRedirectURL(raw); err != nil { + return "", fmt.Errorf("管理端登录结果页无效:%w", err) + } + return raw, nil +} + +func (a *App) enabledAdminOAuthProviders(ctx context.Context) ([]adminOAuthProvider, error) { + providers := make([]adminOAuthProvider, 0, len(adminOAuthProviderNames)) + for _, code := range []string{"wechat", "qq", "github", "google"} { + if !a.configBool(ctx, "oauth."+code+".enabled", false) { + continue + } + provider, err := a.adminOAuthProvider(ctx, code) + if err != nil { + return nil, err + } + providers = append(providers, provider) + } + return providers, nil +} + +func (a *App) oauthConfigurationReady(ctx context.Context) bool { + if _, err := a.adminOAuthFrontendURL(ctx); err != nil { + return false + } + if _, err := a.userOAuthFrontendURL(ctx); err != nil { + return false + } + if _, err := a.enabledAdminOAuthProviders(ctx); err != nil { + return false + } + _, err := a.enabledUserOAuthProviders(ctx) + return err == nil +} + +func (a *App) validateAdminOAuthConfigValues(ctx context.Context, values map[string]string, clearSecrets map[string]bool) error { + value := func(key string) string { + if clearSecrets[key] { + return "" + } + if candidate, exists := values[key]; exists { + if candidate != "" || !strings.HasSuffix(key, "client_secret") { + return strings.TrimSpace(candidate) + } + } + return strings.TrimSpace(a.configPlain(ctx, key, "")) + } + frontendURL := value("oauth.admin.frontend_callback_url") + if frontendURL == "" { + return errors.New("管理端登录结果页不能为空") + } + if err := a.validateAdminOAuthRedirectURL(frontendURL); err != nil { + return fmt.Errorf("管理端登录结果页无效:%w", err) + } + userFrontendURL := value("oauth.user.frontend_callback_url") + if userFrontendURL == "" { + return errors.New("客户端 H5 登录结果页不能为空") + } + if err := a.validateAdminOAuthRedirectURL(userFrontendURL); err != nil { + return fmt.Errorf("客户端 H5 登录结果页无效:%w", err) + } + for _, code := range []string{"wechat", "qq", "github", "google"} { + adminEnabled := strings.ToLower(value("oauth."+code+".enabled")) == "true" + userEnabled := strings.ToLower(value("oauth.user."+code+".enabled")) == "true" + if !adminEnabled && !userEnabled { + continue + } + prefix := "oauth." + code + "." + required := []string{"client_id", "client_secret", "authorization_url", "token_url", "userinfo_url", "scope", "redirect_uri"} + if code == "qq" { + required = append(required, "openid_url") + } + for _, suffix := range required { + if value(prefix+suffix) == "" { + return fmt.Errorf("%s登录的%s不能为空", adminOAuthProviderNames[code], suffix) + } + } + for label, raw := range map[string]string{ + "授权地址": value(prefix + "authorization_url"), + "令牌地址": value(prefix + "token_url"), + "用户信息地址": value(prefix + "userinfo_url"), + } { + if err := validateAdminOAuthEndpoint(code, raw); err != nil { + return fmt.Errorf("%s%s无效:%w", adminOAuthProviderNames[code], label, err) + } + } + if code == "qq" { + if err := validateAdminOAuthEndpoint(code, value(prefix+"openid_url")); err != nil { + return fmt.Errorf("QQ OpenID 地址无效:%w", err) + } + } + if err := a.validateAdminOAuthRedirectURL(value(prefix + "redirect_uri")); err != nil { + return fmt.Errorf("%s回调地址无效:%w", adminOAuthProviderNames[code], err) + } + } + return nil +} + +func (a *App) adminOAuthProviders(w http.ResponseWriter, r *http.Request) { + items := make([]map[string]string, 0, len(adminOAuthProviderNames)) + for _, code := range []string{"wechat", "qq", "github", "google"} { + if !a.configBool(r.Context(), "oauth."+code+".enabled", false) { + continue + } + provider, err := a.adminOAuthProvider(r.Context(), code) + if err != nil { + // A broken channel must not hide other correctly configured channels. + continue + } + items = append(items, map[string]string{"code": provider.Code, "name": provider.Name}) + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) adminOAuthStart(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "请选择第三方登录渠道") + return + } + req.Provider = strings.ToLower(strings.TrimSpace(req.Provider)) + if !a.rateLimit(w, r, "admin_oauth_start", clientIP(r), 30, 10*time.Minute) { + return + } + if !a.configBool(r.Context(), "oauth."+req.Provider+".enabled", false) { + fail(w, http.StatusBadRequest, 20001, "该登录方式未启用") + return + } + provider, err := a.adminOAuthProvider(r.Context(), req.Provider) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "该登录方式配置不完整") + return + } + state := randomToken() + verifier := "" + if provider.Code == "github" || provider.Code == "google" { + verifier = randomToken() + randomToken() + } + _, err = a.db.ExecContext(r.Context(), `INSERT INTO admin_oauth_states(state_hash,provider,code_verifier,expires_at) VALUES(?,?,?,?)`, oauthHash(state), provider.Code, verifier, time.Now().Add(adminOAuthStateTTL)) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建第三方登录请求失败") + return + } + a.cleanupAdminOAuthRecords(r.Context()) + + authorizationURL, _ := url.Parse(provider.AuthorizationURL) + query := authorizationURL.Query() + if provider.Code == "wechat" { + query.Set("appid", provider.ClientID) + } else { + query.Set("client_id", provider.ClientID) + } + query.Set("redirect_uri", provider.RedirectURI) + query.Set("response_type", "code") + query.Set("scope", provider.Scope) + query.Set("state", state) + if verifier != "" { + query.Set("code_challenge", pkceChallenge(verifier)) + query.Set("code_challenge_method", "S256") + } + authorizationURL.RawQuery = query.Encode() + if provider.Code == "wechat" { + authorizationURL.Fragment = "wechat_redirect" + } + reply(w, map[string]string{"authorizationUrl": authorizationURL.String(), "provider": provider.Code}) +} + +func (a *App) adminOAuthCallback(w http.ResponseWriter, r *http.Request) { + frontendURL, frontendErr := a.adminOAuthFrontendURL(r.Context()) + if frontendErr != nil { + fail(w, http.StatusServiceUnavailable, 50001, "第三方登录回调未配置") + return + } + state := strings.TrimSpace(r.URL.Query().Get("state")) + if state == "" { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "登录状态无效或已过期") + return + } + var providerCode, verifier string + err := a.db.QueryRowContext(r.Context(), `SELECT provider,code_verifier FROM admin_oauth_states WHERE state_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(state)).Scan(&providerCode, &verifier) + if err != nil { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "登录状态无效或已过期") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE admin_oauth_states SET used_at=NOW(3) WHERE state_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(state)) + if err != nil { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "第三方登录处理失败") + return + } + affected, _ := result.RowsAffected() + if affected != 1 { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "登录状态已被使用") + return + } + if providerError := strings.TrimSpace(r.URL.Query().Get("error")); providerError != "" { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "第三方授权已取消或失败") + return + } + code := strings.TrimSpace(r.URL.Query().Get("code")) + if code == "" { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "第三方平台未返回授权码") + return + } + if !a.configBool(r.Context(), "oauth."+providerCode+".enabled", false) { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "该登录方式已停用") + return + } + provider, err := a.adminOAuthProvider(r.Context(), providerCode) + if err != nil { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "该登录方式配置不可用") + return + } + identity, err := a.fetchAdminOAuthIdentity(r.Context(), provider, code, verifier) + if err != nil { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "获取第三方账号信息失败") + return + } + var adminUserID sql.NullInt64 + _ = a.db.QueryRowContext(r.Context(), `SELECT admin_user_id FROM admin_oauth_identities WHERE provider=? AND subject=?`, provider.Code, identity.Subject).Scan(&adminUserID) + loginCode := randomToken() + _, err = a.db.ExecContext(r.Context(), `INSERT INTO admin_oauth_login_codes(code_hash,provider,subject,email,display_name,avatar_url,admin_user_id,expires_at) VALUES(?,?,?,?,?,?,?,?)`, oauthHash(loginCode), provider.Code, identity.Subject, identity.Email, identity.DisplayName, identity.AvatarURL, adminUserID, time.Now().Add(adminOAuthCodeTTL)) + if err != nil { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "创建登录凭证失败") + return + } + a.redirectAdminOAuthResult(w, r, frontendURL, loginCode, "") +} + +func (a *App) redirectAdminOAuthResult(w http.ResponseWriter, r *http.Request, frontendURL, code, message string) { + target, err := url.Parse(frontendURL) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "登录结果页地址无效") + return + } + query := target.Query() + if code != "" { + query.Set("oauthCode", code) + } else { + query.Set("oauthError", message) + } + target.RawQuery = query.Encode() + http.Redirect(w, r, target.String(), http.StatusFound) +} + +func (a *App) adminOAuthExchange(w http.ResponseWriter, r *http.Request) { + var req struct { + Code string `json:"code"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效") + return + } + if !a.rateLimit(w, r, "admin_oauth_exchange", clientIP(r), 20, 10*time.Minute) { + return + } + loginCode, err := a.readAdminOAuthLoginCode(r.Context(), strings.TrimSpace(req.Code), false) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效或已过期") + return + } + if !loginCode.AdminUserID.Valid { + reply(w, map[string]any{ + "requiresLink": true, + "provider": loginCode.Provider, + "providerName": adminOAuthProviderNames[loginCode.Provider], + "displayName": loginCode.DisplayName, + "email": loginCode.Email, + "avatarUrl": loginCode.AvatarURL, + }) + return + } + adminID, realName, err := a.consumeAdminOAuthCode(r.Context(), strings.TrimSpace(req.Code), loginCode.AdminUserID.Int64) + if err != nil { + fail(w, http.StatusUnauthorized, 10001, err.Error()) + return + } + payload, err := a.newAdminSession(r.Context(), w, r, adminID, realName) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建管理会话失败") + return + } + payload["requiresLink"] = false + reply(w, payload) +} + +func (a *App) adminOAuthLink(w http.ResponseWriter, r *http.Request) { + var req struct { + Code string `json:"code"` + Username string `json:"username"` + Password string `json:"password"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" || strings.TrimSpace(req.Username) == "" || req.Password == "" { + fail(w, http.StatusBadRequest, 20001, "请输入管理员账号和密码完成绑定") + return + } + username := strings.TrimSpace(req.Username) + if !a.rateLimit(w, r, "admin_oauth_link_ip", clientIP(r), 10, 15*time.Minute) || !a.rateLimit(w, r, "admin_oauth_link_user", strings.ToLower(username), 10, 15*time.Minute) { + return + } + loginCode, err := a.readAdminOAuthLoginCode(r.Context(), strings.TrimSpace(req.Code), true) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效或已过期") + return + } + var adminID int64 + var passwordHash, realName string + var status int + err = a.db.QueryRowContext(r.Context(), `SELECT id,password_hash,real_name,status FROM admin_users WHERE username=?`, username).Scan(&adminID, &passwordHash, &realName, &status) + if err != nil || !checkPassword(passwordHash, req.Password) { + // 这里返回 400,避免前端全局 401 拦截器丢弃尚可重试的一次性绑定码。 + fail(w, http.StatusBadRequest, 10001, "管理员账号或密码错误") + return + } + if status != 1 { + fail(w, http.StatusForbidden, 10006, "管理员账号已停用") + return + } + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(r.Context(), `UPDATE admin_oauth_login_codes SET used_at=NOW(3),admin_user_id=? WHERE code_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, adminID, oauthHash(strings.TrimSpace(req.Code))) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + affected, _ := result.RowsAffected() + if affected != 1 { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证已被使用") + return + } + _, err = tx.ExecContext(r.Context(), `INSERT INTO admin_oauth_identities(provider,subject,admin_user_id,email,display_name,avatar_url,last_login_at) VALUES(?,?,?,?,?,?,NOW(3))`, loginCode.Provider, loginCode.Subject, adminID, loginCode.Email, loginCode.DisplayName, loginCode.AvatarURL) + if err != nil { + fail(w, http.StatusConflict, 20001, "该第三方账号或管理员账号已绑定此渠道") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_users SET last_login_at=NOW(3) WHERE id=?`, adminID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + if err = tx.Commit(); err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + auditPayload, _ := json.Marshal(map[string]any{"provider": loginCode.Provider, "externalSubjectHash": fmt.Sprintf("%x", sha256.Sum256([]byte(loginCode.Subject)))}) + _, _ = a.db.ExecContext(r.Context(), `INSERT INTO admin_audit_logs(admin_user_id,action,target_type,target_id,request_data,ip) VALUES(?,?,?,?,?,?)`, adminID, "bind_oauth_identity", "admin_user", adminID, auditPayload, clientIP(r)) + payload, err := a.newAdminSession(r.Context(), w, r, adminID, realName) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建管理会话失败") + return + } + payload["requiresLink"] = false + reply(w, payload) +} + +func (a *App) readAdminOAuthLoginCode(ctx context.Context, code string, requireUnlinked bool) (adminOAuthLoginCode, error) { + var result adminOAuthLoginCode + query := `SELECT provider,subject,email,display_name,avatar_url,admin_user_id FROM admin_oauth_login_codes WHERE code_hash=? AND used_at IS NULL AND expires_at>NOW(3)` + if requireUnlinked { + query += ` AND admin_user_id IS NULL` + } + err := a.db.QueryRowContext(ctx, query, oauthHash(code)).Scan(&result.Provider, &result.Subject, &result.Email, &result.DisplayName, &result.AvatarURL, &result.AdminUserID) + return result, err +} + +func (a *App) consumeAdminOAuthCode(ctx context.Context, code string, adminID int64) (int64, string, error) { + var realName string + var status int + if err := a.db.QueryRowContext(ctx, `SELECT real_name,status FROM admin_users WHERE id=?`, adminID).Scan(&realName, &status); err != nil || status != 1 { + return 0, "", errors.New("管理员账号不存在或已停用") + } + result, err := a.db.ExecContext(ctx, `UPDATE admin_oauth_login_codes SET used_at=NOW(3) WHERE code_hash=? AND admin_user_id=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(code), adminID) + if err != nil { + return 0, "", errors.New("第三方登录处理失败") + } + affected, _ := result.RowsAffected() + if affected != 1 { + return 0, "", errors.New("第三方登录凭证无效或已使用") + } + _, _ = a.db.ExecContext(ctx, `UPDATE admin_users SET last_login_at=NOW(3) WHERE id=?`, adminID) + _, _ = a.db.ExecContext(ctx, `UPDATE admin_oauth_identities SET last_login_at=NOW(3) WHERE admin_user_id=?`, adminID) + return adminID, realName, nil +} + +func (a *App) cleanupAdminOAuthRecords(ctx context.Context) { + _, _ = a.db.ExecContext(ctx, `DELETE FROM admin_oauth_states WHERE expires_at= 300 { + return "", "", errors.New("第三方令牌服务请求失败") + } + var payload struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if json.Unmarshal(body, &payload) != nil || payload.AccessToken == "" { + parsed, parseErr := url.ParseQuery(string(body)) + if parseErr != nil { + return "", "", errors.New("第三方令牌响应无效") + } + payload.AccessToken = parsed.Get("access_token") + payload.Error = parsed.Get("error") + } + if payload.Error != "" || payload.AccessToken == "" { + return "", "", errors.New("第三方平台拒绝了令牌请求") + } + return payload.AccessToken, "", nil +} + +func (a *App) oauthBearerJSON(ctx context.Context, endpoint, accessToken string, out any) error { + request, _ := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + request.Header.Set("Authorization", "Bearer "+accessToken) + request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", "XingYu-Admin-OAuth/1.0") + return a.oauthDoJSON(request, out) +} + +func (a *App) oauthGetJSON(ctx context.Context, endpoint string, values url.Values, out any) error { + parsed, err := url.Parse(endpoint) + if err != nil { + return err + } + query := parsed.Query() + for key, items := range values { + for _, item := range items { + query.Add(key, item) + } + } + parsed.RawQuery = query.Encode() + request, _ := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", "XingYu-Admin-OAuth/1.0") + return a.oauthDoJSON(request, out) +} + +func (a *App) oauthDoJSON(request *http.Request, out any) error { + response, err := a.oauthHTTPClient().Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("第三方平台返回 HTTP %d", response.StatusCode) + } + decoder := json.NewDecoder(io.LimitReader(response.Body, adminOAuthBodyMax)) + return decoder.Decode(out) +} + +func (a *App) fetchQQOpenID(ctx context.Context, endpoint, accessToken string) (string, error) { + parsed, _ := url.Parse(endpoint) + query := parsed.Query() + query.Set("access_token", accessToken) + query.Set("fmt", "json") + parsed.RawQuery = query.Encode() + request, _ := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + request.Header.Set("Accept", "application/json") + response, err := a.oauthHTTPClient().Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, adminOAuthBodyMax)) + if err != nil || response.StatusCode < 200 || response.StatusCode >= 300 { + return "", errors.New("QQ OpenID 请求失败") + } + text := strings.TrimSpace(string(body)) + if strings.HasPrefix(text, "callback") { + start, end := strings.Index(text, "("), strings.LastIndex(text, ")") + if start >= 0 && end > start { + text = text[start+1 : end] + } + } + var payload struct { + OpenID string `json:"openid"` + Error int `json:"error"` + } + if json.Unmarshal([]byte(text), &payload) != nil || payload.Error != 0 || payload.OpenID == "" { + return "", errors.New("QQ OpenID 响应无效") + } + return payload.OpenID, nil +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_oauth_test.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_oauth_test.go new file mode 100644 index 0000000..2b43844 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_oauth_test.go @@ -0,0 +1,74 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestAdminOAuthEndpointAllowlist(t *testing.T) { + valid := map[string]string{ + "wechat": "https://api.weixin.qq.com/sns/userinfo", + "qq": "https://graph.qq.com/user/get_user_info", + "github": "https://api.github.com/user", + "google": "https://openidconnect.googleapis.com/v1/userinfo", + } + for provider, endpoint := range valid { + if err := validateAdminOAuthEndpoint(provider, endpoint); err != nil { + t.Fatalf("expected %s endpoint to be accepted: %v", provider, err) + } + } + invalid := []struct { + provider string + endpoint string + }{ + {"github", "http://api.github.com/user"}, + {"github", "https://127.0.0.1/user"}, + {"google", "https://evil.example.com/token"}, + {"github", "https://api.github.com:8443/user"}, + {"qq", "javascript:alert(1)"}, + } + for _, item := range invalid { + if err := validateAdminOAuthEndpoint(item.provider, item.endpoint); err == nil { + t.Fatalf("expected endpoint to be rejected: %s", item.endpoint) + } + } +} + +func TestAdminOAuthRedirectURLPolicy(t *testing.T) { + production := &App{config: productionConfigForTest()} + if err := production.validateAdminOAuthRedirectURL("https://admin.example.com/auth/social-callback"); err != nil { + t.Fatalf("expected HTTPS callback to be accepted: %v", err) + } + if err := production.validateAdminOAuthRedirectURL("http://localhost:5560/auth/social-callback"); err == nil { + t.Fatal("expected production HTTP callback to be rejected") + } + development := &App{config: Config{Environment: "development"}} + if err := development.validateAdminOAuthRedirectURL("http://127.0.0.1:8888/admin/v1/auth/oauth/callback"); err != nil { + t.Fatalf("expected local development callback to be accepted: %v", err) + } + if err := development.validateAdminOAuthRedirectURL("http://admin.example.com/callback"); err == nil { + t.Fatal("expected non-local HTTP callback to be rejected") + } +} + +func TestUserOAuthResultKeepsHashRouteAndAddsQuery(t *testing.T) { + app := &App{} + request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/callback", nil) + recorder := httptest.NewRecorder() + app.redirectUserOAuthResult(recorder, request, "http://localhost:5174/#/pages/auth/oauth-callback", "one-time-code", "") + if recorder.Code != http.StatusFound { + t.Fatalf("expected redirect status, got %d", recorder.Code) + } + target, err := url.Parse(recorder.Header().Get("Location")) + if err != nil { + t.Fatalf("invalid redirect URL: %v", err) + } + if target.Query().Get("oauthCode") != "one-time-code" { + t.Fatalf("missing one-time code in redirect: %s", target.String()) + } + if target.Fragment != "/pages/auth/oauth-callback" { + t.Fatalf("hash route was lost: %s", target.String()) + } +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_operations.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_operations.go new file mode 100644 index 0000000..ef3990a --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_operations.go @@ -0,0 +1,572 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +func (a *App) loadAdminUserDetail(w http.ResponseWriter, r *http.Request, id int64) { + var publicID, nickname, avatar, cover, cityCode, city, occupation, bio string + var phoneCipher []byte + var birthday sql.NullString + var created time.Time + var lastActive sql.NullTime + var status, risk, gender, height, profileScore, vip, vipLevel int + var followingCount, followerCount, postCount, likeCount int + var isTest bool + var testBatch string + err := a.db.QueryRowContext(r.Context(), `SELECT u.public_id,u.phone_cipher,u.status,u.risk_level,u.created_at,u.is_test,u.test_batch, + p.nickname,p.avatar_url,p.cover_url,p.gender,DATE_FORMAT(p.birthday,'%Y-%m-%d'),COALESCE(p.height_cm,0),p.city_code,p.city_name,p.occupation,p.bio,p.profile_score,p.is_vip,p.vip_level,p.last_active_at, + (SELECT COUNT(*) FROM user_follows WHERE user_id=u.id),(SELECT COUNT(*) FROM user_follows WHERE target_user_id=u.id),(SELECT COUNT(*) FROM posts WHERE user_id=u.id AND deleted_at IS NULL),(SELECT COUNT(*) FROM post_likes pl JOIN posts po ON po.id=pl.post_id WHERE po.user_id=u.id) + FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.id=? AND u.deleted_at IS NULL`, id).Scan( + &publicID, &phoneCipher, &status, &risk, &created, &isTest, &testBatch, &nickname, &avatar, &cover, &gender, &birthday, &height, &cityCode, &city, &occupation, &bio, &profileScore, &vip, &vipLevel, &lastActive, &followingCount, &followerCount, &postCount, &likeCount) + if err != nil { + fail(w, http.StatusNotFound, 30001, "用户不存在") + return + } + phone, decryptErr := a.decryptPhone(phoneCipher) + if decryptErr != nil { + fail(w, http.StatusInternalServerError, 50001, "用户手机号解密失败") + return + } + + verification := map[string]any{"status": "UNVERIFIED", "type": "real_name", "realName": "", "documentMask": "", "remark": "", "evidence": []string{}} + verifications := []map[string]any{} + verificationRows, _ := a.db.QueryContext(r.Context(), `SELECT verification_type,status,real_name,document_mask,remark,COALESCE(evidence_json,'[]'),submitted_at,reviewed_at FROM user_verifications WHERE user_id=? ORDER BY verification_type='real_name' DESC,verification_type`, id) + if verificationRows != nil { + defer verificationRows.Close() + for verificationRows.Next() { + var verificationType, verificationStatus, realName, documentMask, verificationRemark, evidenceJSON string + var submittedAt, reviewedAt sql.NullTime + if verificationRows.Scan(&verificationType, &verificationStatus, &realName, &documentMask, &verificationRemark, &evidenceJSON, &submittedAt, &reviewedAt) != nil { + continue + } + evidence := []string{} + _ = json.Unmarshal([]byte(evidenceJSON), &evidence) + item := map[string]any{"type": verificationType, "status": verificationStatus, "realName": realName, "documentMask": documentMask, "remark": verificationRemark, "evidence": evidence, "submittedAt": nullableTime(submittedAt), "reviewedAt": nullableTime(reviewedAt)} + verifications = append(verifications, item) + if len(verifications) == 1 { + verification = item + } + } + } + + membership := map[string]any{"active": vip == 1, "level": vipLevel, "name": "普通用户"} + if vip == 1 { + membership["name"] = "历史会员资料" + } + var subscriptionID, planID int64 + var planName string + var level int + var startedAt, expiresAt time.Time + if a.db.QueryRowContext(r.Context(), `SELECT s.id,s.plan_id,p.name,p.level,s.started_at,s.expires_at FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id WHERE s.user_id=? AND s.status=1 AND s.expires_at>NOW(3) ORDER BY p.level DESC,s.expires_at DESC LIMIT 1`, id).Scan(&subscriptionID, &planID, &planName, &level, &startedAt, &expiresAt) == nil { + membership = map[string]any{"active": true, "subscriptionId": subscriptionID, "planId": planID, "name": planName, "level": level, "startedAt": startedAt, "expiresAt": expiresAt} + } + + devices := []map[string]any{} + rows, _ := a.db.QueryContext(r.Context(), `SELECT device_id,platform,device_model,os_version,app_version,last_ip,last_active_at,status FROM user_devices WHERE user_id=? ORDER BY COALESCE(last_active_at,created_at) DESC LIMIT 20`, id) + if rows != nil { + defer rows.Close() + for rows.Next() { + var deviceID, platform, model, osVersion, appVersion, ip string + var active any + var deviceStatus int + _ = rows.Scan(&deviceID, &platform, &model, &osVersion, &appVersion, &ip, &active, &deviceStatus) + devices = append(devices, map[string]any{"deviceId": deviceID, "platform": platform, "model": model, "osVersion": osVersion, "appVersion": appVersion, "ip": ip, "lastActiveAt": active, "status": deviceStatus}) + } + } + var activeSessions, orderCount, paidCent int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_sessions WHERE user_id=? AND revoked_at IS NULL AND expires_at>NOW(3)`, id).Scan(&activeSessions) + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*),COALESCE(SUM(IF(status='PAID',amount_cent,0)),0) FROM orders WHERE user_id=? AND deleted_at IS NULL`, id).Scan(&orderCount, &paidCent) + + reply(w, map[string]any{ + "id": id, "publicId": publicID, "isTest": isTest, "testBatch": testBatch, "phone": phone, "status": status, "riskLevel": risk, "createdAt": created, + "profile": map[string]any{"id": id, "publicId": publicID, "nickname": nickname, "avatar": avatar, "cover": cover, "gender": gender, "birthday": nullableString(birthday), "height": height, "cityCode": cityCode, "city": city, "occupation": occupation, "bio": bio, "profileScore": profileScore, "vip": vip == 1, "vipLevel": vipLevel, "lastActiveAt": nullableTime(lastActive), "followingCount": followingCount, "followerCount": followerCount, "postCount": postCount, "likeCount": likeCount}, + "verification": verification, "verifications": verifications, "membership": membership, "sanctions": a.sanctionList(r.Context(), id), "devices": devices, + "security": map[string]any{"activeSessions": activeSessions}, "orderSummary": map[string]any{"count": orderCount, "paidCent": paidCent}, + }) +} + +func nullableTime(value sql.NullTime) any { + if value.Valid { + return value.Time + } + return nil +} + +func (a *App) adminUpdateUserProfile(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "用户编号无效") + return + } + var req struct { + Phone string `json:"phone"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Cover string `json:"cover"` + Gender int `json:"gender"` + Birthday string `json:"birthday"` + Height int `json:"height"` + CityCode string `json:"cityCode"` + City string `json:"city"` + Occupation string `json:"occupation"` + Bio string `json:"bio"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.Nickname) == "" || len([]rune(req.Nickname)) > 50 || req.Gender < 0 || req.Gender > 2 || req.Height < 0 || req.Height > 260 { + fail(w, 400, 20001, "用户资料格式不正确") + return + } + if req.Birthday != "" { + if _, err = time.Parse("2006-01-02", req.Birthday); err != nil { + fail(w, 400, 20001, "生日格式应为 YYYY-MM-DD") + return + } + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + defer func() { _ = tx.Rollback() }() + if strings.TrimSpace(req.Phone) != "" { + if !validPhone(req.Phone) { + fail(w, 400, 20001, "手机号格式不正确") + return + } + phoneCipher, encryptErr := a.encryptPhone(req.Phone) + if encryptErr != nil { + fail(w, 500, 50001, "加密手机号失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE users SET phone_hash=?,phone_cipher=? WHERE id=?`, phoneHash(req.Phone), phoneCipher, id); err != nil { + fail(w, http.StatusConflict, 20001, "手机号已被其他账号使用") + return + } + } + _, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET nickname=?,avatar_url=?,cover_url=?,gender=?,birthday=NULLIF(?,''),height_cm=NULLIF(?,0),city_code=?,city_name=?,occupation=?,bio=?,profile_score=GREATEST(profile_score,80) WHERE user_id=?`, strings.TrimSpace(req.Nickname), strings.TrimSpace(req.Avatar), strings.TrimSpace(req.Cover), req.Gender, req.Birthday, req.Height, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.City), strings.TrimSpace(req.Occupation), strings.TrimSpace(req.Bio), id) + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "保存失败") + return + } + a.audit(r, "update_profile", "user", id, map[string]any{"nickname": req.Nickname, "phoneChanged": req.Phone != "", "city": req.City}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminUpdateVerification(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "用户编号无效") + return + } + var req struct { + Type string `json:"type"` + Status string `json:"status"` + RealName string `json:"realName"` + DocumentMask string `json:"documentMask"` + Remark string `json:"remark"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "认证资料格式错误") + return + } + req.Status = strings.ToUpper(strings.TrimSpace(req.Status)) + if req.Type == "" { + req.Type = "real_name" + } + if req.Status != "UNVERIFIED" && req.Status != "PENDING" && req.Status != "VERIFIED" && req.Status != "REJECTED" { + fail(w, 400, 20001, "认证状态无效") + return + } + if req.Status == "REJECTED" && strings.TrimSpace(req.Remark) == "" { + fail(w, 400, 20001, "驳回认证时必须填写原因") + return + } + _, err = a.db.ExecContext(r.Context(), `INSERT INTO user_verifications(user_id,verification_type,status,real_name,document_mask,evidence_json,remark,reviewer_admin_id,submitted_at,reviewed_at) VALUES(?,?,?,?,?,'[]',?,?,IF(?='PENDING',NOW(3),NULL),IF(? IN ('VERIFIED','REJECTED'),NOW(3),NULL)) ON DUPLICATE KEY UPDATE verification_type=VALUES(verification_type),status=VALUES(status),real_name=VALUES(real_name),document_mask=VALUES(document_mask),remark=VALUES(remark),reviewer_admin_id=VALUES(reviewer_admin_id),submitted_at=IF(VALUES(status)='PENDING',COALESCE(submitted_at,NOW(3)),submitted_at),reviewed_at=IF(VALUES(status) IN ('VERIFIED','REJECTED'),NOW(3),NULL)`, id, req.Type, req.Status, strings.TrimSpace(req.RealName), strings.TrimSpace(req.DocumentMask), strings.TrimSpace(req.Remark), current(r).ID, req.Status, req.Status) + if err != nil { + fail(w, 500, 50001, "保存认证结果失败") + return + } + if req.Status == "VERIFIED" || req.Status == "REJECTED" { + title := "认证审核结果" + content := "认证已通过" + if req.Status == "REJECTED" { + content = "认证未通过:" + strings.TrimSpace(req.Remark) + } + a.notifyUser(r.Context(), id, "system", title, content, "verification", id) + } + a.audit(r, "verify", "user", id, map[string]any{"status": req.Status, "type": req.Type, "remark": req.Remark}) + reply(w, map[string]bool{"success": true}) +} + +func parseAdminExpiry(value string, fallbackDays int) (time.Time, error) { + if strings.TrimSpace(value) == "" { + return time.Now().AddDate(0, 0, fallbackDays), nil + } + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return parsed, nil + } + parsed, err := time.ParseInLocation("2006-01-02", value, time.Local) + if err != nil { + return time.Time{}, err + } + return parsed.Add(23*time.Hour + 59*time.Minute + 59*time.Second), nil +} + +func (a *App) adminUpdateMembership(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "用户编号无效") + return + } + var req struct { + Operation string `json:"operation"` + PlanID int64 `json:"planId"` + ExpiresAt string `json:"expiresAt"` + Reason string `json:"reason"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "会员设置格式错误") + return + } + if req.Operation == "" { + req.Operation = "grant" + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "会员设置失败") + return + } + defer func() { _ = tx.Rollback() }() + if req.Operation == "revoke" { + _, err = tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1`, id) + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET is_vip=0,vip_level=0 WHERE user_id=?`, id) + } + } else { + var durationDays, level int + if err = tx.QueryRowContext(r.Context(), `SELECT duration_days,level FROM membership_plans WHERE id=? AND deleted_at IS NULL`, req.PlanID).Scan(&durationDays, &level); err != nil { + fail(w, 400, 20001, "会员套餐不存在") + return + } + expiresAt, parseErr := parseAdminExpiry(req.ExpiresAt, durationDays) + if parseErr != nil || !expiresAt.After(time.Now()) { + fail(w, 400, 20001, "会员到期时间无效") + return + } + _, err = tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1`, id) + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(?,?,'admin',1,NOW(3),?)`, id, req.PlanID, expiresAt) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET is_vip=1,vip_level=? WHERE user_id=?`, level, id) + } + } + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "会员设置失败") + return + } + a.audit(r, "membership_"+req.Operation, "user", id, map[string]any{"planId": req.PlanID, "expiresAt": req.ExpiresAt, "reason": req.Reason}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) forceLogoutUser(ctx context.Context, userID, adminID int64, passwordReset bool) error { + if _, err := a.db.ExecContext(ctx, `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, userID); err != nil { + return err + } + if passwordReset { + _, err := a.db.ExecContext(ctx, `INSERT INTO user_security_controls(user_id,token_version,force_logout_at,password_reset_at,last_operator_admin_id) VALUES(?,1,NOW(3),NOW(3),?) ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=VALUES(force_logout_at),password_reset_at=VALUES(password_reset_at),last_operator_admin_id=VALUES(last_operator_admin_id)`, userID, adminID) + if err == nil { + a.hub.disconnect(userID) + } + return err + } + _, err := a.db.ExecContext(ctx, `INSERT INTO user_security_controls(user_id,token_version,force_logout_at,last_operator_admin_id) VALUES(?,1,NOW(3),?) ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=VALUES(force_logout_at),last_operator_admin_id=VALUES(last_operator_admin_id)`, userID, adminID) + if err == nil { + a.hub.disconnect(userID) + } + return err +} + +func (a *App) adminResetUserPassword(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + var req struct { + NewPassword string `json:"newPassword"` + } + if err != nil || decode(r, &req) != nil || !validUserPassword(req.NewPassword) { + fail(w, 400, 20001, "新密码需为 8-72 位并同时包含字母和数字") + return + } + hash, err := hashPassword(req.NewPassword) + if err != nil { + fail(w, 500, 50001, "密码加密失败") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE users SET password_hash=? WHERE id=? AND deleted_at IS NULL`, hash, id) + if err != nil { + fail(w, 500, 50001, "重置密码失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 || a.forceLogoutUser(r.Context(), id, current(r).ID, true) != nil { + fail(w, 500, 50001, "重置密码失败") + return + } + a.audit(r, "reset_password", "user", id, map[string]any{"forceLogout": true}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminForceLogout(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil || a.forceLogoutUser(r.Context(), id, current(r).ID, false) != nil { + fail(w, 500, 50001, "强制下线失败") + return + } + a.audit(r, "force_logout", "user", id, nil) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) sanctionList(ctx context.Context, userID int64) []map[string]any { + items := []map[string]any{} + rows, err := a.db.QueryContext(ctx, `SELECT s.id,s.sanction_type,s.reason,s.starts_at,s.expires_at,s.status,s.operator_admin_id,COALESCE(a.real_name,''),s.revoked_at,s.created_at FROM user_sanctions s LEFT JOIN admin_users a ON a.id=s.operator_admin_id WHERE s.user_id=? ORDER BY s.created_at DESC LIMIT 100`, userID) + if err != nil { + return items + } + defer rows.Close() + for rows.Next() { + var id, operatorID int64 + var typ, reason, status, operatorName string + var startsAt, createdAt time.Time + var expiresAt, revokedAt any + _ = rows.Scan(&id, &typ, &reason, &startsAt, &expiresAt, &status, &operatorID, &operatorName, &revokedAt, &createdAt) + items = append(items, map[string]any{"id": id, "type": typ, "reason": reason, "startsAt": startsAt, "expiresAt": expiresAt, "status": status, "operatorId": operatorID, "operatorName": operatorName, "revokedAt": revokedAt, "createdAt": createdAt}) + } + return items +} + +func (a *App) createSanction(ctx context.Context, adminID, userID int64, typ, reason string, expiresAt *time.Time) (int64, error) { + result, err := a.db.ExecContext(ctx, `INSERT INTO user_sanctions(user_id,sanction_type,reason,expires_at,operator_admin_id) VALUES(?,?,?,?,?)`, userID, typ, reason, expiresAt, adminID) + if err != nil { + return 0, err + } + id, _ := result.LastInsertId() + if typ == "FREEZE" || typ == "BAN" { + status := 2 + if typ == "BAN" { + status = 3 + } + if _, err = a.db.ExecContext(ctx, `UPDATE users SET status=? WHERE id=?`, status, userID); err == nil { + err = a.forceLogoutUser(ctx, userID, adminID, false) + } + } + if err == nil { + title := map[string]string{"WARNING": "违规警告", "MUTE": "禁言通知", "CONTENT_LIMIT": "内容发布限制", "FREEZE": "账号冻结", "BAN": "账号封禁"}[typ] + a.notifyUser(ctx, userID, "system", title, reason, "sanction", nil) + } + return id, err +} + +func (a *App) adminUserSanctions(w http.ResponseWriter, r *http.Request) { + userID, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "用户编号无效") + return + } + if r.Method == http.MethodGet { + reply(w, map[string]any{"items": a.sanctionList(r.Context(), userID)}) + return + } + var req struct { + Type string `json:"type"` + Reason string `json:"reason"` + ExpiresAt string `json:"expiresAt"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "处罚信息格式错误") + return + } + req.Type = strings.ToUpper(strings.TrimSpace(req.Type)) + allowed := map[string]bool{"WARNING": true, "FREEZE": true, "BAN": true, "MUTE": true, "CONTENT_LIMIT": true} + if !allowed[req.Type] || strings.TrimSpace(req.Reason) == "" { + fail(w, 400, 20001, "请选择处罚类型并填写原因") + return + } + var expiry *time.Time + if req.ExpiresAt != "" { + parsed, parseErr := parseAdminExpiry(req.ExpiresAt, 0) + if parseErr != nil || !parsed.After(time.Now()) { + fail(w, 400, 20001, "处罚到期时间无效") + return + } + expiry = &parsed + } + sanctionID, err := a.createSanction(r.Context(), current(r).ID, userID, req.Type, strings.TrimSpace(req.Reason), expiry) + if err != nil { + fail(w, 500, 50001, "执行处罚失败") + return + } + a.audit(r, "sanction", "user", userID, map[string]any{"sanctionId": sanctionID, "type": req.Type, "reason": req.Reason, "expiresAt": req.ExpiresAt}) + reply(w, map[string]any{"id": sanctionID}) +} + +func (a *App) adminRevokeSanction(w http.ResponseWriter, r *http.Request) { + sanctionID, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "处罚编号无效") + return + } + var userID int64 + var typ string + if err = a.db.QueryRowContext(r.Context(), `SELECT user_id,sanction_type FROM user_sanctions WHERE id=? AND status='ACTIVE'`, sanctionID).Scan(&userID, &typ); err != nil { + fail(w, 404, 30001, "有效处罚不存在") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE user_sanctions SET status='REVOKED',revoked_by=?,revoked_at=NOW(3) WHERE id=? AND status='ACTIVE'`, current(r).ID, sanctionID) + affected, _ := result.RowsAffected() + if err != nil || affected == 0 { + fail(w, 500, 50001, "撤销处罚失败") + return + } + if typ == "FREEZE" || typ == "BAN" { + var bans, freezes int + _ = a.db.QueryRowContext(r.Context(), `SELECT SUM(sanction_type='BAN'),SUM(sanction_type='FREEZE') FROM user_sanctions WHERE user_id=? AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>NOW(3))`, userID).Scan(&bans, &freezes) + status := 1 + if bans > 0 { + status = 3 + } else if freezes > 0 { + status = 2 + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE users SET status=? WHERE id=?`, status, userID) + } + a.audit(r, "revoke_sanction", "user", userID, map[string]any{"sanctionId": sanctionID, "type": typ}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) isSanctionActive(ctx context.Context, userID int64, typ string) bool { + var count int + _ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_sanctions WHERE user_id=? AND sanction_type=? AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>NOW(3))`, userID, typ).Scan(&count) + return count > 0 +} + +func (a *App) normalizeUserStatus(ctx context.Context, userID int64, status int) int { + if status != 2 && status != 3 { + return status + } + var total int + _ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_sanctions WHERE user_id=? AND sanction_type IN ('FREEZE','BAN')`, userID).Scan(&total) + if total == 0 { + return status + } + _, _ = a.db.ExecContext(ctx, `UPDATE user_sanctions SET status='EXPIRED' WHERE user_id=? AND sanction_type IN ('FREEZE','BAN') AND status='ACTIVE' AND expires_at<=NOW(3)`, userID) + var bans, freezes int + _ = a.db.QueryRowContext(ctx, `SELECT SUM(sanction_type='BAN'),SUM(sanction_type='FREEZE') FROM user_sanctions WHERE user_id=? AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>NOW(3))`, userID).Scan(&bans, &freezes) + resolved := 1 + if bans > 0 { + resolved = 3 + } else if freezes > 0 { + resolved = 2 + } + if resolved != status { + _, _ = a.db.ExecContext(ctx, `UPDATE users SET status=? WHERE id=?`, resolved, userID) + } + return resolved +} + +func (a *App) recomputeMembershipTx(ctx context.Context, tx *sql.Tx, userID int64) error { + var level sql.NullInt64 + if err := tx.QueryRowContext(ctx, `SELECT MAX(p.level) FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id WHERE s.user_id=? AND s.status=1 AND s.expires_at>NOW(3)`, userID).Scan(&level); err != nil { + return err + } + if !level.Valid { + _, err := tx.ExecContext(ctx, `UPDATE user_profiles SET is_vip=0,vip_level=0 WHERE user_id=?`, userID) + return err + } + _, err := tx.ExecContext(ctx, `UPDATE user_profiles SET is_vip=1,vip_level=? WHERE user_id=?`, level.Int64, userID) + return err +} + +func (a *App) adminOrderTransition(w http.ResponseWriter, r *http.Request, action string) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "订单编号无效") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "订单操作失败") + return + } + defer func() { _ = tx.Rollback() }() + var userID, planID int64 + var status string + if err = tx.QueryRowContext(r.Context(), `SELECT user_id,product_id,status FROM orders WHERE id=? AND deleted_at IS NULL FOR UPDATE`, id).Scan(&userID, &planID, &status); err != nil { + fail(w, 404, 30001, "订单不存在") + return + } + switch action { + case "pay": + if status != "CREATED" { + fail(w, 400, 20001, "只有待支付订单可标记为已支付") + return + } + var durationDays, level int + if err = tx.QueryRowContext(r.Context(), `SELECT duration_days,level FROM membership_plans WHERE id=? AND deleted_at IS NULL`, planID).Scan(&durationDays, &level); err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='PAID',paid_at=NOW(3) WHERE id=?`, id) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(?,?,?,1,NOW(3),DATE_ADD(NOW(3),INTERVAL ? DAY))`, userID, planID, fmt.Sprintf("admin_order:%d", id), durationDays) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET is_vip=1,vip_level=GREATEST(vip_level,?) WHERE user_id=?`, level, userID) + } + case "close": + if status != "CREATED" { + fail(w, 400, 20001, "只有待支付订单可关闭") + return + } + _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='CLOSED' WHERE id=?`, id) + case "refund": + if status != "PAID" && status != "REFUND_REQUESTED" { + fail(w, 400, 20001, "只有已支付或用户已申请退款的订单可退款") + return + } + _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='REFUNDED' WHERE id=?`, id) + if err == nil { + result, updateErr := tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE user_id=? AND plan_id=? AND status=1 AND source IN (?,?)`, userID, planID, fmt.Sprintf("order:%d", id), fmt.Sprintf("admin_order:%d", id)) + err = updateErr + affected, _ := result.RowsAffected() + if err == nil && affected == 0 { + _, err = tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE id=(SELECT id FROM (SELECT id FROM subscriptions WHERE user_id=? AND plan_id=? AND status=1 ORDER BY started_at DESC LIMIT 1) latest)`, userID, planID) + } + } + if err == nil { + err = a.recomputeMembershipTx(r.Context(), tx, userID) + } + default: + fail(w, 400, 20001, "订单操作无效") + return + } + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "订单操作失败") + return + } + a.audit(r, action, "order", id, map[string]any{"userId": userID, "previousStatus": status}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminMarkOrderPaid(w http.ResponseWriter, r *http.Request) { + if a.configPlain(r.Context(), "payment.mode", "sandbox") == "live" { + fail(w, http.StatusBadRequest, 20001, "生产支付订单只能由已验签的支付回调确认入账") + return + } + a.adminOrderTransition(w, r, "pay") +} + +func (a *App) adminCloseOrder(w http.ResponseWriter, r *http.Request) { + a.adminOrderTransition(w, r, "close") +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_rbac.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_rbac.go new file mode 100644 index 0000000..a54805f --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_rbac.go @@ -0,0 +1,529 @@ +package app + +import ( + "context" + "database/sql" + "net/http" + "regexp" + "sort" + "strings" + "time" +) + +type adminRoleView struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + BuiltIn bool `json:"builtIn"` + Status int `json:"status"` + Permissions []string `json:"permissions"` +} + +var adminAccountPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.-]{2,49}$`) +var adminRoleCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{2,49}$`) + +func (a *App) adminAccess(ctx context.Context, adminID int64) ([]string, []string, error) { + roleRows, err := a.db.QueryContext(ctx, `SELECT DISTINCT r.role_code FROM admin_user_roles ur JOIN admin_roles r ON r.id=ur.role_id WHERE ur.admin_user_id=? AND r.status=1 ORDER BY r.role_code`, adminID) + if err != nil { + return nil, nil, err + } + roles := []string{} + for roleRows.Next() { + var code string + if err = roleRows.Scan(&code); err != nil { + _ = roleRows.Close() + return nil, nil, err + } + roles = append(roles, code) + } + if err = roleRows.Close(); err != nil { + return nil, nil, err + } + + permissionRows, err := a.db.QueryContext(ctx, `SELECT DISTINCT rp.permission_code + FROM admin_user_roles ur + JOIN admin_roles r ON r.id=ur.role_id AND r.status=1 + JOIN admin_role_permissions rp ON rp.role_id=r.id + WHERE ur.admin_user_id=? ORDER BY rp.permission_code`, adminID) + if err != nil { + return nil, nil, err + } + permissions := []string{} + for permissionRows.Next() { + var code string + if err = permissionRows.Scan(&code); err != nil { + _ = permissionRows.Close() + return nil, nil, err + } + permissions = append(permissions, code) + } + err = permissionRows.Close() + return roles, permissions, err +} + +func (a *App) hasAdminPermission(ctx context.Context, adminID int64, permission string) bool { + var allowed int + err := a.db.QueryRowContext(ctx, `SELECT EXISTS( + SELECT 1 FROM admin_user_roles ur + JOIN admin_roles r ON r.id=ur.role_id AND r.status=1 + JOIN admin_role_permissions rp ON rp.role_id=r.id + WHERE ur.admin_user_id=? AND rp.permission_code=?)`, adminID, permission).Scan(&allowed) + return err == nil && allowed == 1 +} + +func (a *App) requireAdminPermission(permission string, next http.HandlerFunc) http.HandlerFunc { + return a.requireAuth("admin", func(w http.ResponseWriter, r *http.Request) { + if !a.hasAdminPermission(r.Context(), current(r).ID, permission) { + fail(w, http.StatusForbidden, 10006, "没有执行该操作的权限") + return + } + next(w, r) + }) +} + +func (a *App) adminCodes(w http.ResponseWriter, r *http.Request) { + _, permissions, err := a.adminAccess(r.Context(), current(r).ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取权限失败") + return + } + reply(w, permissions) +} + +func (a *App) adminRoleIDs(ctx context.Context, adminID int64) []int64 { + rows, err := a.db.QueryContext(ctx, `SELECT role_id FROM admin_user_roles WHERE admin_user_id=? ORDER BY role_id`, adminID) + if err != nil { + return []int64{} + } + defer rows.Close() + result := []int64{} + for rows.Next() { + var id int64 + if rows.Scan(&id) == nil { + result = append(result, id) + } + } + return result +} + +func (a *App) adminAccounts(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + keyword := strings.TrimSpace(r.URL.Query().Get("keyword")) + where := "" + args := []any{} + if keyword != "" { + where = ` WHERE username LIKE ? OR real_name LIKE ?` + like := "%" + keyword + "%" + args = append(args, like, like) + } + var total int64 + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM admin_users`+where, args...).Scan(&total); err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询管理员失败") + return + } + queryArgs := append(append([]any{}, args...), size, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT id,username,real_name,avatar_url,status,last_login_at,password_changed_at,created_at FROM admin_users`+where+` ORDER BY id LIMIT ? OFFSET ?`, queryArgs...) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询管理员失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var username, realName, avatar string + var status int + var lastLogin, passwordChanged sql.NullTime + var createdAt time.Time + if err = rows.Scan(&id, &username, &realName, &avatar, &status, &lastLogin, &passwordChanged, &createdAt); err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取管理员失败") + return + } + roles, _, _ := a.adminAccess(r.Context(), id) + items = append(items, map[string]any{ + "id": id, "username": username, "realName": realName, "avatar": avatar, + "status": status, "roles": roles, "roleIds": a.adminRoleIDs(r.Context(), id), + "lastLoginAt": nullableTime(lastLogin), "passwordChangedAt": nullableTime(passwordChanged), "createdAt": createdAt, + }) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} + +func validateAdminRoleIDs(ctx context.Context, tx *sql.Tx, roleIDs []int64) error { + if len(roleIDs) == 0 { + return sql.ErrNoRows + } + seen := map[int64]bool{} + for _, roleID := range roleIDs { + if roleID <= 0 || seen[roleID] { + return sql.ErrNoRows + } + seen[roleID] = true + var status int + if err := tx.QueryRowContext(ctx, `SELECT status FROM admin_roles WHERE id=?`, roleID).Scan(&status); err != nil || status != 1 { + return sql.ErrNoRows + } + } + return nil +} + +func replaceAdminRoles(ctx context.Context, tx *sql.Tx, adminID int64, roleIDs []int64) error { + if err := validateAdminRoleIDs(ctx, tx, roleIDs); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM admin_user_roles WHERE admin_user_id=?`, adminID); err != nil { + return err + } + for _, roleID := range roleIDs { + if _, err := tx.ExecContext(ctx, `INSERT INTO admin_user_roles(admin_user_id,role_id) VALUES(?,?)`, adminID, roleID); err != nil { + return err + } + } + return nil +} + +func (a *App) adminCreateAccount(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + RealName string `json:"realName"` + Password string `json:"password"` + Avatar string `json:"avatar"` + RoleIDs []int64 `json:"roleIds"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "管理员资料格式错误") + return + } + req.Username = strings.TrimSpace(req.Username) + req.RealName = strings.TrimSpace(req.RealName) + if !adminAccountPattern.MatchString(req.Username) || req.RealName == "" || len([]rune(req.RealName)) > 50 { + fail(w, http.StatusBadRequest, 20001, "账号或姓名格式无效") + return + } + if !strongAdminPassword(req.Password) { + fail(w, http.StatusBadRequest, 20001, "密码至少 12 位,且必须包含大小写字母、数字和特殊字符") + return + } + hash, err := hashPassword(req.Password) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "密码加密失败") + return + } + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建管理员失败") + return + } + defer func() { _ = tx.Rollback() }() + if err = validateAdminRoleIDs(r.Context(), tx, req.RoleIDs); err != nil { + fail(w, http.StatusBadRequest, 20001, "请至少选择一个有效角色") + return + } + result, err := tx.ExecContext(r.Context(), `INSERT INTO admin_users(username,password_hash,real_name,avatar_url,status,password_changed_at) VALUES(?,?,?,?,1,NOW(3))`, req.Username, hash, req.RealName, strings.TrimSpace(req.Avatar)) + if err != nil { + fail(w, http.StatusConflict, 20001, "管理员账号已存在") + return + } + adminID, _ := result.LastInsertId() + if err = replaceAdminRoles(r.Context(), tx, adminID, req.RoleIDs); err != nil || tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "保存管理员角色失败") + return + } + a.audit(r, "create", "admin_user", adminID, map[string]any{"username": req.Username, "roleIds": req.RoleIDs}) + reply(w, map[string]any{"id": adminID, "success": true}) +} + +func containsInt64(values []int64, expected int64) bool { + for _, value := range values { + if value == expected { + return true + } + } + return false +} + +func (a *App) ensureSuperAdminRemains(ctx context.Context, tx *sql.Tx, targetID int64, newStatus int, roleIDs []int64) error { + var superRoleID int64 + if err := tx.QueryRowContext(ctx, `SELECT id FROM admin_roles WHERE role_code='super_admin'`).Scan(&superRoleID); err != nil { + return err + } + var targetWasSuper int + if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM admin_user_roles WHERE admin_user_id=? AND role_id=?)`, targetID, superRoleID).Scan(&targetWasSuper); err != nil { + return err + } + if targetWasSuper == 0 || (newStatus == 1 && containsInt64(roleIDs, superRoleID)) { + return nil + } + var remaining int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT a.id) FROM admin_users a JOIN admin_user_roles ur ON ur.admin_user_id=a.id WHERE a.status=1 AND a.id<>? AND ur.role_id=?`, targetID, superRoleID).Scan(&remaining); err != nil { + return err + } + if remaining == 0 { + return sql.ErrNoRows + } + return nil +} + +func (a *App) adminUpdateAccount(w http.ResponseWriter, r *http.Request) { + adminID, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "管理员 ID 无效") + return + } + var req struct { + RealName string `json:"realName"` + Avatar string `json:"avatar"` + Password string `json:"password"` + Status int `json:"status"` + RoleIDs []int64 `json:"roleIds"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.RealName) == "" || (req.Status != 0 && req.Status != 1) { + fail(w, http.StatusBadRequest, 20001, "管理员资料格式错误") + return + } + if current(r).ID == adminID && req.Status != 1 { + fail(w, http.StatusBadRequest, 20001, "不能停用当前登录账号") + return + } + if req.Password != "" && !strongAdminPassword(req.Password) { + fail(w, http.StatusBadRequest, 20001, "新密码不符合管理员密码强度要求") + return + } + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新管理员失败") + return + } + defer func() { _ = tx.Rollback() }() + var exists int64 + if err = tx.QueryRowContext(r.Context(), `SELECT id FROM admin_users WHERE id=? FOR UPDATE`, adminID).Scan(&exists); err != nil { + fail(w, http.StatusNotFound, 30001, "管理员不存在") + return + } + if err = validateAdminRoleIDs(r.Context(), tx, req.RoleIDs); err != nil { + fail(w, http.StatusBadRequest, 20001, "请至少选择一个有效角色") + return + } + if err = a.ensureSuperAdminRemains(r.Context(), tx, adminID, req.Status, req.RoleIDs); err != nil { + fail(w, http.StatusBadRequest, 20001, "必须保留至少一个启用的超级管理员") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_users SET real_name=?,avatar_url=?,status=? WHERE id=?`, strings.TrimSpace(req.RealName), strings.TrimSpace(req.Avatar), req.Status, adminID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新管理员失败") + return + } + if err = replaceAdminRoles(r.Context(), tx, adminID, req.RoleIDs); err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新管理员角色失败") + return + } + passwordChanged := false + if req.Password != "" { + hash, hashErr := hashPassword(req.Password) + if hashErr != nil { + fail(w, http.StatusInternalServerError, 50001, "密码加密失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_users SET password_hash=?,token_version=token_version+1,password_changed_at=NOW(3) WHERE id=?`, hash, adminID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新管理员密码失败") + return + } + passwordChanged = true + } + if req.Status != 1 || passwordChanged { + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_sessions SET revoked_at=NOW(3) WHERE admin_user_id=? AND revoked_at IS NULL`, adminID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "撤销管理员会话失败") + return + } + } + if err = tx.Commit(); err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新管理员失败") + return + } + a.audit(r, "update", "admin_user", adminID, map[string]any{"roleIds": req.RoleIDs, "status": req.Status, "passwordChanged": passwordChanged}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) rolePermissions(ctx context.Context, roleID int64) []string { + rows, err := a.db.QueryContext(ctx, `SELECT permission_code FROM admin_role_permissions WHERE role_id=? ORDER BY permission_code`, roleID) + if err != nil { + return []string{} + } + defer rows.Close() + permissions := []string{} + for rows.Next() { + var code string + if rows.Scan(&code) == nil { + permissions = append(permissions, code) + } + } + return permissions +} + +func (a *App) adminRoles(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,role_code,role_name,description,built_in,status FROM admin_roles ORDER BY built_in DESC,id`) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询角色失败") + return + } + defer rows.Close() + items := []adminRoleView{} + for rows.Next() { + var item adminRoleView + var builtIn int + if err = rows.Scan(&item.ID, &item.Code, &item.Name, &item.Description, &builtIn, &item.Status); err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取角色失败") + return + } + item.BuiltIn = builtIn == 1 + item.Permissions = a.rolePermissions(r.Context(), item.ID) + items = append(items, item) + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) adminPermissions(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT permission_code,permission_name,permission_group FROM admin_permissions ORDER BY permission_group,sort_order,permission_code`) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询权限失败") + return + } + defer rows.Close() + items := []map[string]string{} + for rows.Next() { + var code, name, group string + if rows.Scan(&code, &name, &group) == nil { + items = append(items, map[string]string{"code": code, "name": name, "group": group}) + } + } + reply(w, map[string]any{"items": items}) +} + +func validatePermissionCodes(ctx context.Context, tx *sql.Tx, permissions []string) error { + if len(permissions) == 0 { + return sql.ErrNoRows + } + seen := map[string]bool{} + for _, permission := range permissions { + permission = strings.TrimSpace(permission) + if permission == "" || seen[permission] { + return sql.ErrNoRows + } + seen[permission] = true + var exists int + if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM admin_permissions WHERE permission_code=?)`, permission).Scan(&exists); err != nil || exists != 1 { + return sql.ErrNoRows + } + } + return nil +} + +func replaceRolePermissions(ctx context.Context, tx *sql.Tx, roleID int64, permissions []string) error { + if err := validatePermissionCodes(ctx, tx, permissions); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM admin_role_permissions WHERE role_id=?`, roleID); err != nil { + return err + } + sort.Strings(permissions) + for _, permission := range permissions { + if _, err := tx.ExecContext(ctx, `INSERT INTO admin_role_permissions(role_id,permission_code) VALUES(?,?)`, roleID, permission); err != nil { + return err + } + } + return nil +} + +func (a *App) adminCreateRole(w http.ResponseWriter, r *http.Request) { + a.adminSaveRole(w, r, 0) +} + +func (a *App) adminUpdateRole(w http.ResponseWriter, r *http.Request) { + roleID, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "角色 ID 无效") + return + } + a.adminSaveRole(w, r, roleID) +} + +func (a *App) adminSaveRole(w http.ResponseWriter, r *http.Request, roleID int64) { + var req struct { + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + Permissions []string `json:"permissions"` + Status int `json:"status"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "角色资料格式错误") + return + } + req.Code = strings.TrimSpace(req.Code) + req.Name = strings.TrimSpace(req.Name) + if !adminRoleCodePattern.MatchString(req.Code) || req.Name == "" || len([]rune(req.Name)) > 80 || (req.Status != 0 && req.Status != 1) { + fail(w, http.StatusBadRequest, 20001, "角色编码、名称或状态无效") + return + } + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存角色失败") + return + } + defer func() { _ = tx.Rollback() }() + if err = validatePermissionCodes(r.Context(), tx, req.Permissions); err != nil { + fail(w, http.StatusBadRequest, 20001, "请至少选择一个有效权限") + return + } + if roleID == 0 { + result, insertErr := tx.ExecContext(r.Context(), `INSERT INTO admin_roles(role_code,role_name,description,status) VALUES(?,?,?,?)`, req.Code, req.Name, strings.TrimSpace(req.Description), req.Status) + if insertErr != nil { + fail(w, http.StatusConflict, 20001, "角色编码已存在") + return + } + roleID, _ = result.LastInsertId() + } else { + var builtIn int + if err = tx.QueryRowContext(r.Context(), `SELECT built_in FROM admin_roles WHERE id=? FOR UPDATE`, roleID).Scan(&builtIn); err != nil { + fail(w, http.StatusNotFound, 30001, "角色不存在") + return + } + if builtIn == 1 { + fail(w, http.StatusBadRequest, 20001, "内置超级管理员角色不可修改") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_roles SET role_code=?,role_name=?,description=?,status=? WHERE id=?`, req.Code, req.Name, strings.TrimSpace(req.Description), req.Status, roleID); err != nil { + fail(w, http.StatusConflict, 20001, "角色编码已存在") + return + } + } + if err = replaceRolePermissions(r.Context(), tx, roleID, req.Permissions); err != nil || tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "保存角色权限失败") + return + } + a.audit(r, "save", "admin_role", roleID, map[string]any{"code": req.Code, "permissions": req.Permissions, "status": req.Status}) + reply(w, map[string]any{"id": roleID, "success": true}) +} + +func (a *App) adminDeleteRole(w http.ResponseWriter, r *http.Request) { + roleID, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "角色 ID 无效") + return + } + var builtIn, assigned int + if err = a.db.QueryRowContext(r.Context(), `SELECT built_in,(SELECT COUNT(*) FROM admin_user_roles WHERE role_id=admin_roles.id) FROM admin_roles WHERE id=?`, roleID).Scan(&builtIn, &assigned); err != nil { + fail(w, http.StatusNotFound, 30001, "角色不存在") + return + } + if builtIn == 1 || assigned > 0 { + fail(w, http.StatusBadRequest, 20001, "内置角色或仍被管理员使用的角色不能删除") + return + } + if _, err = a.db.ExecContext(r.Context(), `DELETE FROM admin_roles WHERE id=?`, roleID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "删除角色失败") + return + } + a.audit(r, "delete", "admin_role", roleID, nil) + reply(w, map[string]bool{"success": true}) +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_sessions.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_sessions.go new file mode 100644 index 0000000..44b157f --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_sessions.go @@ -0,0 +1,183 @@ +package app + +import ( + "context" + "crypto/sha256" + "database/sql" + "errors" + "net/http" + "strings" + "time" +) + +const ( + adminAccessTokenTTL = 30 * time.Minute + adminRefreshTokenTTL = 7 * 24 * time.Hour + adminRefreshCookieKey = "xingyu_admin_refresh" +) + +func adminUserAgentHash(r *http.Request) []byte { + sum := sha256.Sum256([]byte(strings.TrimSpace(r.UserAgent()))) + return sum[:] +} + +func (a *App) setAdminRefreshCookie(w http.ResponseWriter, token string, expiresAt time.Time) { + maxAge := int(time.Until(expiresAt).Seconds()) + if maxAge < 0 { + maxAge = 0 + } + sameSite := http.SameSiteLaxMode + secure := a.config.Environment == "production" + if secure { + // Production supports a dedicated HTTPS API domain while keeping the + // refresh token inaccessible to JavaScript. + sameSite = http.SameSiteNoneMode + } + http.SetCookie(w, &http.Cookie{ + Name: adminRefreshCookieKey, + Value: token, + Path: "/admin/v1/auth", + Expires: expiresAt, + MaxAge: maxAge, + HttpOnly: true, + Secure: secure, + SameSite: sameSite, + }) +} + +func (a *App) clearAdminRefreshCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: adminRefreshCookieKey, + Value: "", + Path: "/admin/v1/auth", + Expires: time.Unix(1, 0), + MaxAge: -1, + HttpOnly: true, + Secure: a.config.Environment == "production", + SameSite: func() http.SameSite { + if a.config.Environment == "production" { + return http.SameSiteNoneMode + } + return http.SameSiteLaxMode + }(), + }) +} + +func (a *App) adminRefreshTokenFromRequest(r *http.Request) string { + if cookie, err := r.Cookie(adminRefreshCookieKey); err == nil { + if token := strings.TrimSpace(cookie.Value); token != "" { + return token + } + } + if r.Body == nil || r.ContentLength == 0 { + return "" + } + var req struct { + RefreshToken string `json:"refreshToken"` + } + if decode(r, &req) != nil { + return "" + } + return strings.TrimSpace(req.RefreshToken) +} + +func (a *App) newAdminSession(ctx context.Context, w http.ResponseWriter, r *http.Request, adminID int64, realName string) (map[string]any, error) { + refreshToken := randomToken() + refreshHash := sha256.Sum256([]byte(refreshToken)) + expiresAt := time.Now().Add(adminRefreshTokenTTL) + if _, err := a.db.ExecContext(ctx, `INSERT INTO admin_sessions(admin_user_id,refresh_token_hash,user_agent_hash,ip,expires_at) VALUES(?,?,?,?,?)`, adminID, refreshHash[:], adminUserAgentHash(r), clientIP(r), expiresAt); err != nil { + return nil, err + } + accessToken, err := a.token(adminID, "admin", realName, adminAccessTokenTTL) + if err != nil { + _, _ = a.db.ExecContext(ctx, `UPDATE admin_sessions SET revoked_at=NOW(3) WHERE refresh_token_hash=?`, refreshHash[:]) + return nil, err + } + a.setAdminRefreshCookie(w, refreshToken, expiresAt) + result := map[string]any{"accessToken": accessToken, "expiresIn": int(adminAccessTokenTTL.Seconds())} + if a.config.Environment != "production" { + // HTTP development across localhost/127.0.0.1 cannot use a SameSite=None + // cookie. The fallback is sessionStorage-only on the admin client. + result["refreshToken"] = refreshToken + } + return result, nil +} + +func (a *App) rotateAdminSession(ctx context.Context, w http.ResponseWriter, r *http.Request, refreshToken string) (map[string]any, error) { + oldHash := sha256.Sum256([]byte(refreshToken)) + tx, err := a.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + + var sessionID, adminID int64 + var realName string + var status int + var expiresAt time.Time + err = tx.QueryRowContext(ctx, `SELECT s.id,a.id,a.real_name,a.status,s.expires_at + FROM admin_sessions s JOIN admin_users a ON a.id=s.admin_user_id + WHERE s.refresh_token_hash=? AND s.revoked_at IS NULL AND s.expires_at>NOW(3) + FOR UPDATE`, oldHash[:]).Scan(&sessionID, &adminID, &realName, &status, &expiresAt) + if err != nil || status != 1 { + return nil, errors.New("刷新令牌无效或已过期") + } + + newRefreshToken := randomToken() + newHash := sha256.Sum256([]byte(newRefreshToken)) + result, err := tx.ExecContext(ctx, `UPDATE admin_sessions SET refresh_token_hash=?,user_agent_hash=?,ip=?,last_active_at=NOW(3) WHERE id=? AND refresh_token_hash=? AND revoked_at IS NULL`, newHash[:], adminUserAgentHash(r), clientIP(r), sessionID, oldHash[:]) + if err != nil { + return nil, err + } + affected, _ := result.RowsAffected() + if affected != 1 { + return nil, errors.New("刷新令牌已被使用") + } + if err = tx.Commit(); err != nil { + return nil, err + } + + accessToken, err := a.token(adminID, "admin", realName, adminAccessTokenTTL) + if err != nil { + return nil, err + } + a.setAdminRefreshCookie(w, newRefreshToken, expiresAt) + payload := map[string]any{"accessToken": accessToken, "expiresIn": int(adminAccessTokenTTL.Seconds())} + if a.config.Environment != "production" { + payload["refreshToken"] = newRefreshToken + } + return payload, nil +} + +func (a *App) revokeAdminSession(ctx context.Context, refreshToken string) { + if strings.TrimSpace(refreshToken) == "" { + return + } + hash := sha256.Sum256([]byte(refreshToken)) + _, _ = a.db.ExecContext(ctx, `UPDATE admin_sessions SET revoked_at=NOW(3) WHERE refresh_token_hash=? AND revoked_at IS NULL`, hash[:]) +} + +func (a *App) adminRefresh(w http.ResponseWriter, r *http.Request) { + if !a.rateLimit(w, r, "admin_refresh", clientIP(r), 120, 10*time.Minute) { + return + } + refreshToken := a.adminRefreshTokenFromRequest(r) + if refreshToken == "" { + a.clearAdminRefreshCookie(w) + fail(w, http.StatusUnauthorized, 10001, "刷新令牌不存在") + return + } + payload, err := a.rotateAdminSession(r.Context(), w, r, refreshToken) + if err != nil { + a.clearAdminRefreshCookie(w) + fail(w, http.StatusUnauthorized, 10001, err.Error()) + return + } + reply(w, payload) +} + +func (a *App) adminLogout(w http.ResponseWriter, r *http.Request) { + a.revokeAdminSession(r.Context(), a.adminRefreshTokenFromRequest(r)) + a.clearAdminRefreshCookie(w) + reply(w, map[string]bool{"success": true}) +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_sessions_test.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_sessions_test.go new file mode 100644 index 0000000..9b84792 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/admin_sessions_test.go @@ -0,0 +1,42 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestAdminRefreshCookieSecurityModes(t *testing.T) { + production := &App{config: Config{Environment: "production"}} + recorder := httptest.NewRecorder() + production.setAdminRefreshCookie(recorder, "token", time.Now().Add(time.Hour)) + cookies := recorder.Result().Cookies() + if len(cookies) != 1 || !cookies[0].HttpOnly || !cookies[0].Secure || cookies[0].SameSite != http.SameSiteNoneMode { + t.Fatalf("unexpected production cookie: %#v", cookies) + } + + development := &App{config: Config{Environment: "development"}} + recorder = httptest.NewRecorder() + development.setAdminRefreshCookie(recorder, "token", time.Now().Add(time.Hour)) + cookies = recorder.Result().Cookies() + if len(cookies) != 1 || cookies[0].Secure || cookies[0].SameSite != http.SameSiteLaxMode { + t.Fatalf("unexpected development cookie: %#v", cookies) + } +} + +func TestAdminRefreshTokenRequestFallback(t *testing.T) { + application := &App{} + request := httptest.NewRequest(http.MethodPost, "/admin/v1/auth/refresh", strings.NewReader(`{"refreshToken":"body-token"}`)) + request.Header.Set("Content-Type", "application/json") + if token := application.adminRefreshTokenFromRequest(request); token != "body-token" { + t.Fatalf("unexpected body token %q", token) + } + + request = httptest.NewRequest(http.MethodPost, "/admin/v1/auth/refresh", strings.NewReader(`{"refreshToken":"body-token"}`)) + request.AddCookie(&http.Cookie{Name: adminRefreshCookieKey, Value: "cookie-token"}) + if token := application.adminRefreshTokenFromRequest(request); token != "cookie-token" { + t.Fatalf("cookie token must take priority, got %q", token) + } +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/app.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/app.go new file mode 100644 index 0000000..2d47366 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/app.go @@ -0,0 +1,496 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "regexp" + "strconv" + "strings" + "time" + + _ "github.com/go-sql-driver/mysql" + "github.com/zeromicro/go-zero/rest" +) + +type Config struct { + Host string + Port int + DSN string + JWTSecret string + ConfigEncryptionKey string + MediaDir string + Environment string + AllowedOrigins []string + SeedDemo bool + BootstrapAdminUsername string + BootstrapAdminPassword string + BootstrapAdminRealName string +} + +type App struct { + config Config + db *sql.DB + hub *Hub +} + +type apiResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data"` + RequestID string `json:"request_id,omitempty"` +} + +type pageResult struct { + Items any `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + Size int `json:"size"` +} + +func LoadConfig() Config { + port, _ := strconv.Atoi(env("IM_PORT", "8888")) + return Config{ + Host: env("IM_HOST", "0.0.0.0"), + Port: port, + DSN: env("IM_DB_DSN", "root:root@tcp(127.0.0.1:3306)/im?charset=utf8mb4&parseTime=True&loc=Local"), + JWTSecret: env("IM_JWT_SECRET", "local-development-secret-change-me"), + ConfigEncryptionKey: env("IM_CONFIG_ENCRYPTION_KEY", ""), + MediaDir: env("IM_MEDIA_DIR", "./uploads"), + Environment: strings.ToLower(env("IM_ENV", "development")), + AllowedOrigins: csvEnv("IM_ALLOWED_ORIGINS", "http://localhost:5173,http://localhost:5174,http://localhost:5180,http://localhost:5555,http://localhost:5556,http://localhost:5560,http://127.0.0.1:5173,http://127.0.0.1:5174,http://127.0.0.1:5180,http://127.0.0.1:5555,http://127.0.0.1:5556,http://127.0.0.1:5560"), + SeedDemo: boolEnv("IM_SEED_DEMO", false), + BootstrapAdminUsername: strings.TrimSpace(os.Getenv("IM_BOOTSTRAP_ADMIN_USERNAME")), + BootstrapAdminPassword: os.Getenv("IM_BOOTSTRAP_ADMIN_PASSWORD"), + BootstrapAdminRealName: env("IM_BOOTSTRAP_ADMIN_REAL_NAME", "平台管理员"), + } +} + +func New(config Config) (*App, error) { + if err := validateConfig(config); err != nil { + return nil, err + } + db, err := sql.Open("mysql", config.DSN) + if err != nil { + return nil, fmt.Errorf("open mysql: %w", err) + } + db.SetMaxOpenConns(30) + db.SetMaxIdleConns(10) + // Keep pooled connections below the local MySQL wait_timeout (120s). + db.SetConnMaxIdleTime(30 * time.Second) + db.SetConnMaxLifetime(90 * time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, fmt.Errorf("connect mysql (run scripts/migrate.ps1 first): %w", err) + } + return &App{config: config, db: db, hub: NewHub()}, nil +} + +func (a *App) Close() { _ = a.db.Close() } + +func (a *App) Run() { + workerContext, stopWorkers := context.WithCancel(context.Background()) + defer stopWorkers() + go func() { + a.processDueAccountClosures(workerContext) + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-workerContext.Done(): + return + case <-ticker.C: + a.processDueAccountClosures(workerContext) + } + } + }() + server := rest.MustNewServer(rest.RestConf{ + Host: a.config.Host, + Port: a.config.Port, + MaxBytes: 16 << 20, + MaxConns: 5_000, + Timeout: 35_000, + }, rest.WithCors(a.config.AllowedOrigins...)) + defer server.Stop() + server.Use(a.requestMetadata) + server.AddRoutes(a.routes()) + log.Printf("星遇 API listening on http://127.0.0.1:%d", a.config.Port) + server.Start() +} + +type responseRecorder struct { + http.ResponseWriter + status int +} + +func (r *responseRecorder) WriteHeader(status int) { + r.status = status + r.ResponseWriter.WriteHeader(status) +} + +var safeRequestID = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`) + +func (a *App) requestMetadata(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + started := time.Now() + requestID := strings.TrimSpace(r.Header.Get("X-Request-ID")) + if !safeRequestID.MatchString(requestID) { + requestID = randomToken()[:32] + } + w.Header().Set("X-Request-ID", requestID) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/admin/") { + w.Header().Set("Cache-Control", "no-store") + } + if r.URL.Path == "/ws" { + next(w, r) + log.Printf("request_id=%s method=%s path=%s status=%d duration_ms=%d ip=%s", requestID, r.Method, r.URL.Path, http.StatusSwitchingProtocols, time.Since(started).Milliseconds(), clientIP(r)) + return + } + recorder := &responseRecorder{ResponseWriter: w, status: http.StatusOK} + next(recorder, r) + log.Printf("request_id=%s method=%s path=%s status=%d duration_ms=%d ip=%s", requestID, r.Method, r.URL.Path, recorder.status, time.Since(started).Milliseconds(), clientIP(r)) + } +} + +func (a *App) routes() []rest.Route { + routes := []rest.Route{ + {Method: http.MethodGet, Path: "/healthz", Handler: a.health}, + {Method: http.MethodPost, Path: "/api/v1/auth/sms/send", Handler: a.sendSMS}, + {Method: http.MethodPost, Path: "/api/v1/auth/register", Handler: a.register}, + {Method: http.MethodPost, Path: "/api/v1/auth/login/password", Handler: a.loginPassword}, + {Method: http.MethodPost, Path: "/api/v1/auth/login/sms", Handler: a.loginSMS}, + {Method: http.MethodGet, Path: "/api/v1/auth/oauth/providers", Handler: a.userOAuthProviders}, + {Method: http.MethodPost, Path: "/api/v1/auth/oauth/start", Handler: a.userOAuthStart}, + {Method: http.MethodGet, Path: "/api/v1/auth/oauth/callback", Handler: a.oauthCallback}, + {Method: http.MethodPost, Path: "/api/v1/auth/oauth/exchange", Handler: a.userOAuthExchange}, + {Method: http.MethodPost, Path: "/api/v1/auth/oauth/link", Handler: a.userOAuthLink}, + {Method: http.MethodPost, Path: "/api/v1/auth/password/reset", Handler: a.resetPassword}, + {Method: http.MethodPost, Path: "/api/v1/auth/token/refresh", Handler: a.refreshToken}, + {Method: http.MethodGet, Path: "/api/v1/membership/plans", Handler: a.membershipPlans}, + {Method: http.MethodGet, Path: "/api/v1/payment/channels", Handler: a.paymentChannels}, + {Method: http.MethodPost, Path: "/api/v1/payment/notify", Handler: a.paymentNotify}, + {Method: http.MethodGet, Path: "/api/v1/app/config", Handler: a.appConfig}, + {Method: http.MethodGet, Path: "/uploads/:name", Handler: a.serveMedia}, + {Method: http.MethodHead, Path: "/uploads/:name", Handler: a.serveMedia}, + {Method: http.MethodPost, Path: "/admin/v1/auth/login", Handler: a.adminLogin}, + {Method: http.MethodPost, Path: "/admin/v1/auth/refresh", Handler: a.adminRefresh}, + {Method: http.MethodPost, Path: "/admin/v1/auth/logout", Handler: a.adminLogout}, + {Method: http.MethodGet, Path: "/admin/v1/auth/oauth/providers", Handler: a.adminOAuthProviders}, + {Method: http.MethodPost, Path: "/admin/v1/auth/oauth/start", Handler: a.adminOAuthStart}, + {Method: http.MethodGet, Path: "/admin/v1/auth/oauth/callback", Handler: a.oauthCallback}, + {Method: http.MethodPost, Path: "/admin/v1/auth/oauth/exchange", Handler: a.adminOAuthExchange}, + {Method: http.MethodPost, Path: "/admin/v1/auth/oauth/link", Handler: a.adminOAuthLink}, + } + routes = append(routes, a.userRoutes()...) + routes = append(routes, a.adminRoutes()...) + return routes +} + +func (a *App) userRoutes() []rest.Route { + auth := func(next http.HandlerFunc) http.HandlerFunc { return a.requireAuth("user", next) } + return []rest.Route{ + {Method: http.MethodPost, Path: "/api/v1/auth/logout", Handler: auth(a.logout)}, + {Method: http.MethodGet, Path: "/api/v1/me", Handler: auth(a.me)}, + {Method: http.MethodGet, Path: "/api/v1/me/profile", Handler: auth(a.me)}, + {Method: http.MethodPatch, Path: "/api/v1/me/profile", Handler: auth(a.updateProfile)}, + {Method: http.MethodPut, Path: "/api/v1/me/password", Handler: auth(a.changeUserPassword)}, + {Method: http.MethodPut, Path: "/api/v1/me/phone", Handler: auth(a.changeUserPhone)}, + {Method: http.MethodGet, Path: "/api/v1/me/devices", Handler: auth(a.myDevices)}, + {Method: http.MethodDelete, Path: "/api/v1/me/devices/:id", Handler: auth(a.revokeDevice)}, + {Method: http.MethodGet, Path: "/api/v1/me/blocks", Handler: auth(a.blockedUsers)}, + {Method: http.MethodGet, Path: "/api/v1/me/notification-settings", Handler: auth(a.notificationSettings)}, + {Method: http.MethodPut, Path: "/api/v1/me/notification-settings", Handler: auth(a.updateNotificationSettings)}, + {Method: http.MethodPost, Path: "/api/v1/me/push-tokens", Handler: auth(a.registerPushToken)}, + {Method: http.MethodDelete, Path: "/api/v1/me/push-tokens", Handler: auth(a.deletePushToken)}, + {Method: http.MethodGet, Path: "/api/v1/me/feedback", Handler: auth(a.feedback)}, + {Method: http.MethodPost, Path: "/api/v1/me/feedback", Handler: auth(a.feedback)}, + {Method: http.MethodGet, Path: "/api/v1/me/account-closure", Handler: auth(a.accountClosure)}, + {Method: http.MethodPost, Path: "/api/v1/me/account-closure", Handler: auth(a.accountClosure)}, + {Method: http.MethodDelete, Path: "/api/v1/me/account-closure", Handler: auth(a.accountClosure)}, + {Method: http.MethodGet, Path: "/api/v1/me/data-export", Handler: auth(a.exportMyData)}, + {Method: http.MethodPost, Path: "/api/v1/me/consents", Handler: auth(a.recordConsent)}, + {Method: http.MethodGet, Path: "/api/v1/users/search", Handler: auth(a.searchUsers)}, + {Method: http.MethodGet, Path: "/api/v1/tags", Handler: auth(a.availableTags)}, + {Method: http.MethodGet, Path: "/api/v1/users/:id", Handler: auth(a.userProfile)}, + {Method: http.MethodGet, Path: "/api/v1/me/following", Handler: auth(a.followingList)}, + {Method: http.MethodGet, Path: "/api/v1/me/followers", Handler: auth(a.followerList)}, + {Method: http.MethodGet, Path: "/api/v1/me/visitors", Handler: auth(a.visitorList)}, + {Method: http.MethodGet, Path: "/api/v1/me/privacy", Handler: auth(a.getPrivacy)}, + {Method: http.MethodPut, Path: "/api/v1/me/privacy", Handler: auth(a.updatePrivacy)}, + {Method: http.MethodGet, Path: "/api/v1/me/verification", Handler: auth(a.myVerification)}, + {Method: http.MethodPost, Path: "/api/v1/me/verification", Handler: auth(a.submitVerification)}, + {Method: http.MethodGet, Path: "/api/v1/discover/recommendations", Handler: auth(a.discover)}, + {Method: http.MethodGet, Path: "/api/v1/nearby/users", Handler: auth(a.nearby)}, + {Method: http.MethodPut, Path: "/api/v1/location", Handler: auth(a.updateLocation)}, + {Method: http.MethodPost, Path: "/api/v1/users/:id/follow", Handler: auth(a.follow)}, + {Method: http.MethodDelete, Path: "/api/v1/users/:id/follow", Handler: auth(a.unfollow)}, + {Method: http.MethodPost, Path: "/api/v1/users/:id/like", Handler: auth(a.likeUser)}, + {Method: http.MethodDelete, Path: "/api/v1/users/:id/like", Handler: auth(a.unlikeUser)}, + {Method: http.MethodPost, Path: "/api/v1/users/:id/block", Handler: auth(a.blockUser)}, + {Method: http.MethodDelete, Path: "/api/v1/users/:id/block", Handler: auth(a.unblockUser)}, + {Method: http.MethodGet, Path: "/api/v1/feed", Handler: auth(a.feed)}, + {Method: http.MethodGet, Path: "/api/v1/posts/:id", Handler: auth(a.postDetail)}, + {Method: http.MethodGet, Path: "/api/v1/users/:id/posts", Handler: auth(a.userPosts)}, + {Method: http.MethodPost, Path: "/api/v1/posts", Handler: auth(a.createPost)}, + {Method: http.MethodPatch, Path: "/api/v1/posts/:id", Handler: auth(a.updatePost)}, + {Method: http.MethodDelete, Path: "/api/v1/posts/:id", Handler: auth(a.deleteOwnPost)}, + {Method: http.MethodPost, Path: "/api/v1/media/upload", Handler: auth(a.uploadMedia)}, + {Method: http.MethodPost, Path: "/api/v1/posts/:id/like", Handler: auth(a.likePost)}, + {Method: http.MethodDelete, Path: "/api/v1/posts/:id/like", Handler: auth(a.unlikePost)}, + {Method: http.MethodGet, Path: "/api/v1/posts/:id/comments", Handler: auth(a.comments)}, + {Method: http.MethodPost, Path: "/api/v1/posts/:id/comments", Handler: auth(a.createComment)}, + {Method: http.MethodDelete, Path: "/api/v1/comments/:id", Handler: auth(a.deleteOwnComment)}, + {Method: http.MethodPost, Path: "/api/v1/im/conversations/direct", Handler: auth(a.directConversation)}, + {Method: http.MethodGet, Path: "/api/v1/im/conversations", Handler: auth(a.conversations)}, + {Method: http.MethodGet, Path: "/api/v1/im/conversations/:id/messages", Handler: auth(a.messages)}, + {Method: http.MethodPost, Path: "/api/v1/im/conversations/:id/messages", Handler: auth(a.sendMessageHTTP)}, + {Method: http.MethodPost, Path: "/api/v1/im/messages/:id/recall", Handler: auth(a.recallMessage)}, + {Method: http.MethodPatch, Path: "/api/v1/im/conversations/:id/settings", Handler: auth(a.conversationSettings)}, + {Method: http.MethodGet, Path: "/api/v1/membership/status", Handler: auth(a.membershipStatus)}, + {Method: http.MethodPost, Path: "/api/v1/orders", Handler: auth(a.createOrder)}, + {Method: http.MethodPost, Path: "/api/v1/orders/:id/pay", Handler: auth(a.payOrder)}, + {Method: http.MethodGet, Path: "/api/v1/orders/:id", Handler: auth(a.orderStatus)}, + {Method: http.MethodPost, Path: "/api/v1/orders/:id/close", Handler: auth(a.closeOwnOrder)}, + {Method: http.MethodPost, Path: "/api/v1/orders/:id/refund", Handler: auth(a.requestOrderRefund)}, + {Method: http.MethodGet, Path: "/api/v1/me/orders", Handler: auth(a.myOrders)}, + {Method: http.MethodGet, Path: "/api/v1/notifications", Handler: auth(a.notifications)}, + {Method: http.MethodPost, Path: "/api/v1/notifications/read-all", Handler: auth(a.readAllNotifications)}, + {Method: http.MethodPost, Path: "/api/v1/notifications/:id/read", Handler: auth(a.readNotification)}, + {Method: http.MethodPost, Path: "/api/v1/reports", Handler: auth(a.createReport)}, + {Method: http.MethodGet, Path: "/api/v1/me/reports", Handler: auth(a.myReports)}, + {Method: http.MethodGet, Path: "/ws", Handler: a.websocket}, + } +} + +func (a *App) adminRoutes() []rest.Route { + auth := func(next http.HandlerFunc) http.HandlerFunc { return a.requireAuth("admin", next) } + permit := func(permission string, next http.HandlerFunc) http.HandlerFunc { + return a.requireAdminPermission(permission, next) + } + return []rest.Route{ + {Method: http.MethodPut, Path: "/admin/v1/me/password", Handler: auth(a.adminChangePassword)}, + {Method: http.MethodGet, Path: "/admin/v1/auth/codes", Handler: auth(a.adminCodes)}, + {Method: http.MethodGet, Path: "/admin/v1/user/info", Handler: auth(a.adminInfo)}, + {Method: http.MethodGet, Path: "/admin/v1/dashboard/overview", Handler: permit("dashboard:view", a.dashboard)}, + {Method: http.MethodGet, Path: "/admin/v1/users", Handler: permit("users:view", a.adminUsers)}, + {Method: http.MethodGet, Path: "/admin/v1/users/:id", Handler: permit("users:view", a.adminUserDetail)}, + {Method: http.MethodPut, Path: "/admin/v1/users/:id/profile", Handler: permit("users:manage", a.adminUpdateUserProfile)}, + {Method: http.MethodPut, Path: "/admin/v1/users/:id/verification", Handler: permit("verification:manage", a.adminUpdateVerification)}, + {Method: http.MethodPut, Path: "/admin/v1/users/:id/membership", Handler: permit("users:manage", a.adminUpdateMembership)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/password-reset", Handler: permit("users:security", a.adminResetUserPassword)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/force-logout", Handler: permit("users:security", a.adminForceLogout)}, + {Method: http.MethodGet, Path: "/admin/v1/users/:id/sanctions", Handler: permit("violations:manage", a.adminUserSanctions)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/sanctions", Handler: permit("violations:manage", a.adminUserSanctions)}, + {Method: http.MethodPost, Path: "/admin/v1/sanctions/:id/revoke", Handler: permit("violations:manage", a.adminRevokeSanction)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/freeze", Handler: permit("violations:manage", a.adminUserStatus)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/unfreeze", Handler: permit("violations:manage", a.adminUserStatus)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/ban", Handler: permit("violations:manage", a.adminUserStatus)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/unban", Handler: permit("violations:manage", a.adminUserStatus)}, + {Method: http.MethodGet, Path: "/admin/v1/posts", Handler: permit("content:view", a.adminPosts)}, + {Method: http.MethodGet, Path: "/admin/v1/posts/:id", Handler: permit("content:view", a.adminPostDetail)}, + {Method: http.MethodDelete, Path: "/admin/v1/posts/:id", Handler: permit("content:manage", a.adminDeletePost)}, + {Method: http.MethodGet, Path: "/admin/v1/reports", Handler: permit("reports:handle", a.adminReports)}, + {Method: http.MethodPost, Path: "/admin/v1/reports/:id/handle", Handler: permit("reports:handle", a.adminHandleReport)}, + {Method: http.MethodGet, Path: "/admin/v1/risk/users", Handler: permit("risk:view", a.adminRiskUsers)}, + {Method: http.MethodGet, Path: "/admin/v1/risk/events", Handler: permit("risk:view", a.adminRiskEvents)}, + {Method: http.MethodGet, Path: "/admin/v1/membership/plans", Handler: permit("membership:manage", a.adminPlans)}, + {Method: http.MethodPost, Path: "/admin/v1/membership/plans", Handler: permit("membership:manage", a.adminCreatePlan)}, + {Method: http.MethodPatch, Path: "/admin/v1/membership/plans/:id", Handler: permit("membership:manage", a.adminUpdatePlan)}, + {Method: http.MethodPut, Path: "/admin/v1/membership/plans/:id", Handler: permit("membership:manage", a.adminUpdatePlan)}, + {Method: http.MethodDelete, Path: "/admin/v1/membership/plans/:id", Handler: permit("membership:manage", a.adminDeletePlan)}, + {Method: http.MethodGet, Path: "/admin/v1/orders", Handler: permit("orders:view", a.adminOrders)}, + {Method: http.MethodPut, Path: "/admin/v1/orders/:id", Handler: permit("orders:manage", a.adminUpdateOrder)}, + {Method: http.MethodDelete, Path: "/admin/v1/orders/:id", Handler: permit("orders:manage", a.adminDeleteOrder)}, + {Method: http.MethodPost, Path: "/admin/v1/orders/:id/pay", Handler: permit("orders:manage", a.adminMarkOrderPaid)}, + {Method: http.MethodPost, Path: "/admin/v1/orders/:id/close", Handler: permit("orders:manage", a.adminCloseOrder)}, + {Method: http.MethodPost, Path: "/admin/v1/orders/:id/refund", Handler: permit("orders:manage", a.adminRefund)}, + {Method: http.MethodGet, Path: "/admin/v1/messages", Handler: permit("messages:view", a.adminMessages)}, + {Method: http.MethodPost, Path: "/admin/v1/messages/:id/moderate", Handler: permit("messages:manage", a.adminModerateMessage)}, + {Method: http.MethodGet, Path: "/admin/v1/client-feedback", Handler: permit("users:view", a.adminFeedback)}, + {Method: http.MethodPut, Path: "/admin/v1/client-feedback/:id", Handler: permit("users:manage", a.adminFeedback)}, + {Method: http.MethodGet, Path: "/admin/v1/account-closures", Handler: permit("users:view", a.adminAccountClosures)}, + {Method: http.MethodPut, Path: "/admin/v1/account-closures/:id", Handler: permit("users:manage", a.adminAccountClosures)}, + {Method: http.MethodGet, Path: "/admin/v1/app-versions", Handler: permit("system:manage", a.adminAppVersions)}, + {Method: http.MethodPost, Path: "/admin/v1/app-versions", Handler: permit("system:manage", a.adminAppVersions)}, + {Method: http.MethodPut, Path: "/admin/v1/app-versions/:id", Handler: permit("system:manage", a.adminAppVersions)}, + {Method: http.MethodDelete, Path: "/admin/v1/app-versions/:id", Handler: permit("system:manage", a.adminAppVersions)}, + {Method: http.MethodGet, Path: "/admin/v1/system/configs", Handler: permit("system:manage", a.adminConfigs)}, + {Method: http.MethodPatch, Path: "/admin/v1/system/configs/:key", Handler: permit("system:manage", a.adminUpdateConfig)}, + {Method: http.MethodPut, Path: "/admin/v1/system/configs/:key", Handler: permit("system:manage", a.adminUpdateConfig)}, + {Method: http.MethodGet, Path: "/admin/v1/integrations/:group", Handler: permit("system:manage", a.adminIntegration)}, + {Method: http.MethodPut, Path: "/admin/v1/integrations/:group", Handler: permit("system:manage", a.adminUpdateIntegration)}, + {Method: http.MethodPost, Path: "/admin/v1/integrations/:group/test", Handler: permit("system:manage", a.adminTestIntegration)}, + {Method: http.MethodGet, Path: "/admin/v1/audit-logs", Handler: permit("system:manage", a.adminAuditLogs)}, + {Method: http.MethodGet, Path: "/admin/v1/admin-users", Handler: permit("system:manage", a.adminAccounts)}, + {Method: http.MethodPost, Path: "/admin/v1/admin-users", Handler: permit("system:manage", a.adminCreateAccount)}, + {Method: http.MethodPut, Path: "/admin/v1/admin-users/:id", Handler: permit("system:manage", a.adminUpdateAccount)}, + {Method: http.MethodGet, Path: "/admin/v1/admin-roles", Handler: permit("system:manage", a.adminRoles)}, + {Method: http.MethodPost, Path: "/admin/v1/admin-roles", Handler: permit("system:manage", a.adminCreateRole)}, + {Method: http.MethodPut, Path: "/admin/v1/admin-roles/:id", Handler: permit("system:manage", a.adminUpdateRole)}, + {Method: http.MethodDelete, Path: "/admin/v1/admin-roles/:id", Handler: permit("system:manage", a.adminDeleteRole)}, + {Method: http.MethodGet, Path: "/admin/v1/admin-permissions", Handler: permit("system:manage", a.adminPermissions)}, + } +} + +func (a *App) health(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), time.Second) + defer cancel() + if err := a.db.PingContext(ctx); err != nil { + fail(w, http.StatusServiceUnavailable, 50001, "database unavailable") + return + } + reply(w, map[string]any{"status": "ok", "time": time.Now()}) +} + +func reply(w http.ResponseWriter, data any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(apiResponse{Code: 0, Message: "OK", Data: data}) +} + +func fail(w http.ResponseWriter, status, code int, message string) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(apiResponse{Code: code, Message: message, Data: nil}) +} + +func decode(r *http.Request, out any) error { + decoder := json.NewDecoder(io.LimitReader(r.Body, 2<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(out); err != nil { + return fmt.Errorf("invalid request: %w", err) + } + return nil +} + +func pagination(r *http.Request) (int, int, int) { + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + size, _ := strconv.Atoi(r.URL.Query().Get("size")) + if page < 1 { + page = 1 + } + if size < 1 { + size = 20 + } + if size > 100 { + size = 100 + } + return page, size, (page - 1) * size +} + +func pathID(r *http.Request) (int64, error) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + for i := len(parts) - 1; i >= 0; i-- { + if id, err := strconv.ParseInt(parts[i], 10, 64); err == nil { + return id, nil + } + } + return 0, errors.New("invalid id") +} + +func env(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func boolEnv(key string, fallback bool) bool { + value := strings.TrimSpace(strings.ToLower(os.Getenv(key))) + if value == "" { + return fallback + } + return value == "1" || value == "true" || value == "yes" || value == "on" +} + +func csvEnv(key, fallback string) []string { + value := env(key, fallback) + items := make([]string, 0) + seen := map[string]bool{} + for _, item := range strings.Split(value, ",") { + item = strings.TrimRight(strings.TrimSpace(item), "/") + if item != "" && !seen[item] { + seen[item] = true + items = append(items, item) + } + } + return items +} + +func validateConfig(config Config) error { + if config.Port < 1 || config.Port > 65535 { + return fmt.Errorf("IM_PORT 无效") + } + if config.Environment != "production" { + return nil + } + if config.SeedDemo { + return fmt.Errorf("生产环境禁止启用 IM_SEED_DEMO") + } + if len(config.JWTSecret) < 32 || config.JWTSecret == "local-development-secret-change-me" { + return fmt.Errorf("生产环境必须配置至少 32 字节的 IM_JWT_SECRET") + } + if len(config.ConfigEncryptionKey) < 32 { + return fmt.Errorf("生产环境必须配置至少 32 字节的 IM_CONFIG_ENCRYPTION_KEY") + } + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(config.DSN)), "root:") { + return fmt.Errorf("生产环境禁止使用 root 数据库账号") + } + if len(config.AllowedOrigins) == 0 { + return fmt.Errorf("生产环境必须配置 IM_ALLOWED_ORIGINS") + } + for _, origin := range config.AllowedOrigins { + if origin == "*" { + return fmt.Errorf("生产环境禁止使用通配 CORS 来源") + } + if !strings.HasPrefix(origin, "https://") { + return fmt.Errorf("生产环境来源必须使用 HTTPS: %s", origin) + } + } + if config.BootstrapAdminPassword != "" && !strongAdminPassword(config.BootstrapAdminPassword) { + return fmt.Errorf("IM_BOOTSTRAP_ADMIN_PASSWORD 至少 12 位,且必须包含大小写字母、数字和特殊字符") + } + return nil +} + +func strongAdminPassword(value string) bool { + if len(value) < 12 { + return false + } + var lower, upper, digit, special bool + for _, char := range value { + switch { + case char >= 'a' && char <= 'z': + lower = true + case char >= 'A' && char <= 'Z': + upper = true + case char >= '0' && char <= '9': + digit = true + default: + special = true + } + } + return lower && upper && digit && special +} + +func (a *App) originAllowed(origin string) bool { + origin = strings.TrimRight(strings.TrimSpace(origin), "/") + if origin == "" { + return true + } + for _, allowed := range a.config.AllowedOrigins { + if origin == allowed { + return true + } + } + return false +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/auth.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/auth.go new file mode 100644 index 0000000..9d4459c --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/auth.go @@ -0,0 +1,298 @@ +package app + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/base64" + "encoding/hex" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/bcrypt" +) + +type identity struct { + ID int64 + Role string + Name string + IssuedAt time.Time + Version int +} + +type identityKey struct{} + +type tokenClaims struct { + Role string `json:"role"` + Name string `json:"name"` + Version int `json:"ver,omitempty"` + jwt.RegisteredClaims +} + +func (a *App) token(id int64, role, name string, ttl time.Duration) (string, error) { + now := time.Now() + version := 0 + if role == "user" { + _ = a.db.QueryRow(`SELECT token_version FROM user_security_controls WHERE user_id=?`, id).Scan(&version) + } else if role == "admin" { + _ = a.db.QueryRow(`SELECT token_version FROM admin_users WHERE id=?`, id).Scan(&version) + } + claims := tokenClaims{ + Role: role, + Name: name, + Version: version, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: strconv.FormatInt(id, 10), + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(a.config.JWTSecret)) +} + +func (a *App) parseToken(raw string) (identity, error) { + parsed, err := jwt.ParseWithClaims(raw, &tokenClaims{}, func(t *jwt.Token) (any, error) { + if t.Method != jwt.SigningMethodHS256 { + return nil, fmt.Errorf("unexpected signing method") + } + return []byte(a.config.JWTSecret), nil + }) + if err != nil || !parsed.Valid { + return identity{}, fmt.Errorf("invalid token") + } + claims, ok := parsed.Claims.(*tokenClaims) + if !ok { + return identity{}, fmt.Errorf("invalid claims") + } + id, err := strconv.ParseInt(claims.Subject, 10, 64) + if err != nil { + return identity{}, fmt.Errorf("invalid subject") + } + issuedAt := time.Time{} + if claims.IssuedAt != nil { + issuedAt = claims.IssuedAt.Time + } + return identity{ID: id, Role: claims.Role, Name: claims.Name, IssuedAt: issuedAt, Version: claims.Version}, nil +} + +func (a *App) requireAuth(role string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + raw := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + if raw == "" { + fail(w, http.StatusUnauthorized, 10001, "请先登录") + return + } + who, err := a.parseToken(raw) + if err != nil || who.Role != role { + fail(w, http.StatusUnauthorized, 10001, "登录状态已失效") + return + } + if role == "user" { + if a.executeDueAccountClosure(r, who.ID) { + fail(w, http.StatusUnauthorized, 10001, "账号已按注销申请完成删除") + return + } + var status int + if err = a.db.QueryRowContext(r.Context(), `SELECT status FROM users WHERE id=? AND deleted_at IS NULL`, who.ID).Scan(&status); err == nil { + status = a.normalizeUserStatus(r.Context(), who.ID, status) + } + if err != nil || status != 1 { + fail(w, http.StatusForbidden, 10006, "账号已被冻结或封禁") + return + } + var forceLogout sql.NullTime + var tokenVersion int + _ = a.db.QueryRowContext(r.Context(), `SELECT force_logout_at,token_version FROM user_security_controls WHERE user_id=?`, who.ID).Scan(&forceLogout, &tokenVersion) + if who.Version != tokenVersion || (forceLogout.Valid && !who.IssuedAt.IsZero() && who.IssuedAt.Unix() < forceLogout.Time.Unix()) { + fail(w, http.StatusUnauthorized, 10001, "登录状态已失效,请重新登录") + return + } + } else if role == "admin" { + var status, tokenVersion int + if err = a.db.QueryRowContext(r.Context(), `SELECT status,token_version FROM admin_users WHERE id=?`, who.ID).Scan(&status, &tokenVersion); err != nil || status != 1 { + fail(w, http.StatusForbidden, 10006, "管理员账号已停用") + return + } + if who.Version != tokenVersion { + fail(w, http.StatusUnauthorized, 10001, "密码已修改,请重新登录") + return + } + } + next(w, r.WithContext(context.WithValue(r.Context(), identityKey{}, who))) + } +} + +func current(r *http.Request) identity { + who, _ := r.Context().Value(identityKey{}).(identity) + return who +} + +func hashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(hash), err +} + +// Compatibility-only rollback: retain original policies while accepting newly stored hashes. +const longPasswordHashPrefix = "$argon2id$v=19$m=19456,t=2,p=1$" + +func checkPassword(hash, password string) bool { + if strings.HasPrefix(hash, "$argon2id$") { + // Only our supported parameters are accepted; never allocate memory from + // unchecked parameters in a corrupt or externally supplied hash. + payload, ok := strings.CutPrefix(hash, longPasswordHashPrefix) + if !ok || len(payload) != 66 { + return false + } + parts := strings.Split(payload, "$") + if len(parts) != 2 { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[0]) + if err != nil || len(salt) != 16 { + return false + } + expected, err := base64.RawStdEncoding.DecodeString(parts[1]) + if err != nil || len(expected) != 32 { + return false + } + key := argon2.IDKey([]byte(password), salt, 2, 19*1024, 1, 32) + return subtle.ConstantTimeCompare(key, expected) == 1 + } + if len(password) > 72 { + return false + } + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +func validUserPassword(password string) bool { + if len(password) < 8 || len(password) > 72 { + return false + } + var letter, digit bool + for _, char := range password { + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') { + letter = true + } + if char >= '0' && char <= '9' { + digit = true + } + } + return letter && digit +} + +func validPhone(value string) bool { + value = strings.TrimSpace(value) + if len(value) != 11 || value[0] != '1' || value[1] < '3' || value[1] > '9' { + return false + } + for _, char := range value { + if char < '0' || char > '9' { + return false + } + } + return true +} + +func phoneHash(phone string) []byte { + sum := sha256.Sum256([]byte(strings.TrimSpace(phone))) + return sum[:] +} + +var encryptedPhonePrefix = []byte("enc:v1:") + +func (a *App) piiEncryptionKey() [32]byte { + key := a.config.ConfigEncryptionKey + if key == "" { + key = a.config.JWTSecret + ":development-pii" + } + return sha256.Sum256([]byte(key + ":phone")) +} + +func (a *App) encryptPhone(phone string) ([]byte, error) { + key := a.piiEncryptionKey() + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err = rand.Read(nonce); err != nil { + return nil, err + } + payload := append(append([]byte{}, encryptedPhonePrefix...), nonce...) + payload = gcm.Seal(payload, nonce, []byte(strings.TrimSpace(phone)), nil) + return payload, nil +} + +func (a *App) decryptPhone(payload []byte) (string, error) { + if !bytes.HasPrefix(payload, encryptedPhonePrefix) { + return string(payload), nil + } + key := a.piiEncryptionKey() + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + ciphertext := payload[len(encryptedPhonePrefix):] + if len(ciphertext) < gcm.NonceSize() { + return "", fmt.Errorf("invalid encrypted phone") + } + plain, err := gcm.Open(nil, ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():], nil) + return string(plain), err +} + +func (a *App) encryptLegacyPhones(ctx context.Context) error { + rows, err := a.db.QueryContext(ctx, `SELECT id,phone_cipher FROM users WHERE phone_cipher IS NOT NULL AND LEFT(phone_cipher,7)<>?`, encryptedPhonePrefix) + if err != nil { + return err + } + type legacyPhone struct { + id int64 + payload []byte + } + items := []legacyPhone{} + for rows.Next() { + var item legacyPhone + if err = rows.Scan(&item.id, &item.payload); err != nil { + _ = rows.Close() + return err + } + items = append(items, item) + } + if err = rows.Close(); err != nil { + return err + } + for _, item := range items { + encrypted, encryptErr := a.encryptPhone(string(item.payload)) + if encryptErr != nil { + return encryptErr + } + if _, err = a.db.ExecContext(ctx, `UPDATE users SET phone_cipher=? WHERE id=? AND phone_cipher=?`, encrypted, item.id, item.payload); err != nil { + return err + } + } + return nil +} + +func randomToken() string { + buf := make([]byte, 32) + _, _ = rand.Read(buf) + return hex.EncodeToString(buf) +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/client_productization.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/client_productization.go new file mode 100644 index 0000000..3831a6e --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/client_productization.go @@ -0,0 +1,749 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +type membershipEntitlements struct { + DailyActiveChatLimit int `json:"dailyActiveChatLimit"` + DailyLikeLimit int `json:"dailyLikeLimit"` + CanViewVisitors bool `json:"canViewVisitors"` + CanInvisibleVisit bool `json:"canInvisibleVisit"` + RecommendationWeight int `json:"recommendationWeight"` +} + +func (a *App) validateOwnedImageEvidence(ctx context.Context, userID int64, rawURLs []string) ([]string, error) { + items := make([]string, 0, len(rawURLs)) + seen := map[string]bool{} + for _, rawURL := range rawURLs { + mediaURL := strings.TrimSpace(rawURL) + if mediaURL == "" || seen[mediaURL] { + return nil, fmt.Errorf("证据图片地址无效或重复") + } + seen[mediaURL] = true + var exists int + if err := a.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM media_assets WHERE owner_user_id=? AND public_url=? AND media_type='image' AND status=1 AND moderation_status=1)`, userID, mediaURL).Scan(&exists); err != nil || exists != 1 { + return nil, fmt.Errorf("证据图片必须由当前账号上传") + } + items = append(items, mediaURL) + } + return items, nil +} + +func (a *App) resolveMembershipEntitlements(ctx context.Context, userID int64) membershipEntitlements { + result := membershipEntitlements{DailyActiveChatLimit: a.resolveDailyActiveChatLimit(ctx, a.db, userID), DailyLikeLimit: 20} + _ = a.db.QueryRowContext(ctx, `SELECT CAST(config_value AS UNSIGNED) FROM system_configs WHERE config_key='membership.free_daily_like_limit'`).Scan(&result.DailyLikeLimit) + var visitors, invisible int + _ = a.db.QueryRowContext(ctx, `SELECT p.daily_like_limit,p.can_view_visitors,p.can_invisible_visit,p.recommendation_weight + FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id + WHERE s.user_id=? AND s.status=1 AND s.started_at<=NOW(3) AND s.expires_at>NOW(3) AND p.deleted_at IS NULL + ORDER BY p.level DESC,s.expires_at DESC LIMIT 1`, userID).Scan(&result.DailyLikeLimit, &visitors, &invisible, &result.RecommendationWeight) + result.CanViewVisitors = visitors == 1 + result.CanInvisibleVisit = invisible == 1 + return result +} + +type dailyLikeLimitError struct{ Limit int } + +func (e *dailyLikeLimitError) Error() string { + return fmt.Sprintf("今日点赞次数已达上限(%d次)", e.Limit) +} + +func (a *App) reserveDailyLike(tx *sql.Tx, r *http.Request, targetType string, targetID int64) error { + userID := current(r).ID + if _, err := tx.ExecContext(r.Context(), `INSERT IGNORE INTO user_daily_like_usage(user_id,usage_date,used_count) VALUES(?,CURRENT_DATE(),0)`, userID); err != nil { + return err + } + var used int + if err := tx.QueryRowContext(r.Context(), `SELECT used_count FROM user_daily_like_usage WHERE user_id=? AND usage_date=CURRENT_DATE() FOR UPDATE`, userID).Scan(&used); err != nil { + return err + } + var counted int + if err := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM user_daily_like_targets WHERE user_id=? AND target_type=? AND target_id=? AND usage_date=CURRENT_DATE())`, userID, targetType, targetID).Scan(&counted); err != nil { + return err + } + if counted == 1 { + return nil + } + entitlements := a.resolveMembershipEntitlements(r.Context(), userID) + if entitlements.DailyLikeLimit > 0 && used >= entitlements.DailyLikeLimit { + return &dailyLikeLimitError{Limit: entitlements.DailyLikeLimit} + } + if _, err := tx.ExecContext(r.Context(), `INSERT INTO user_daily_like_targets(user_id,target_type,target_id,usage_date) VALUES(?,?,?,CURRENT_DATE())`, userID, targetType, targetID); err != nil { + return err + } + _, err := tx.ExecContext(r.Context(), `UPDATE user_daily_like_usage SET used_count=used_count+1 WHERE user_id=? AND usage_date=CURRENT_DATE()`, userID) + return err +} + +func pageOptions(r *http.Request) (int, int, int) { + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + pageSize, _ := strconv.Atoi(r.URL.Query().Get("pageSize")) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 50 { + pageSize = 50 + } + return page, pageSize, (page - 1) * pageSize +} + +func nullableInt64(value sql.NullInt64) any { + if value.Valid { + return value.Int64 + } + return nil +} + +func (a *App) availableTags(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,category,name,icon,sort_order FROM tags WHERE status=1 ORDER BY category,sort_order,id`) + if err != nil { + fail(w, 500, 50001, "查询标签失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var category, name, icon string + var order int + if rows.Scan(&id, &category, &name, &icon, &order) == nil { + items = append(items, map[string]any{"id": id, "category": category, "name": name, "icon": icon, "sortOrder": order}) + } + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) myDevices(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT s.id,s.device_id,COALESCE(d.platform,''),COALESCE(d.device_model,''),COALESCE(d.os_version,''),COALESCE(d.app_version,''),COALESCE(d.last_ip,''),s.last_active_at,s.expires_at,s.created_at + FROM user_sessions s LEFT JOIN user_devices d ON d.user_id=s.user_id AND d.device_id=s.device_id + WHERE s.user_id=? AND s.revoked_at IS NULL AND s.expires_at>NOW(3) ORDER BY s.last_active_at DESC`, current(r).ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询登录设备失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var deviceID, platform, model, osVersion, appVersion, ip string + var active, expires, created time.Time + if rows.Scan(&id, &deviceID, &platform, &model, &osVersion, &appVersion, &ip, &active, &expires, &created) == nil { + items = append(items, map[string]any{"id": id, "deviceId": deviceID, "platform": platform, "deviceModel": model, "osVersion": osVersion, "appVersion": appVersion, "lastIp": ip, "lastActiveAt": active, "expiresAt": expires, "createdAt": created}) + } + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) revokeDevice(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "设备会话编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE id=? AND user_id=? AND revoked_at IS NULL`, id, current(r).ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "移除设备失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusNotFound, 30001, "设备会话不存在") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) changeUserPassword(w http.ResponseWriter, r *http.Request) { + var req struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` + } + if decode(r, &req) != nil || !validUserPassword(req.NewPassword) { + fail(w, http.StatusBadRequest, 20001, "新密码需为 8-72 位并同时包含字母和数字") + return + } + var oldHash string + if a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=? AND deleted_at IS NULL`, current(r).ID).Scan(&oldHash) != nil || !checkPassword(oldHash, req.CurrentPassword) { + fail(w, http.StatusUnauthorized, 10001, "当前密码错误") + return + } + if checkPassword(oldHash, req.NewPassword) { + fail(w, http.StatusBadRequest, 20001, "新密码不能与当前密码相同") + return + } + newHash, _ := hashPassword(req.NewPassword) + tx, err := a.db.BeginTx(r.Context(), nil) + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE users SET password_hash=? WHERE id=?`, newHash, current(r).ID) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, current(r).ID) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT INTO user_security_controls(user_id,token_version,force_logout_at,password_reset_at) VALUES(?,1,NOW(3),NOW(3)) ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=NOW(3),password_reset_at=NOW(3)`, current(r).ID) + } + if err != nil || tx.Commit() != nil { + _ = tx.Rollback() + fail(w, http.StatusInternalServerError, 50001, "修改密码失败") + return + } + a.hub.disconnect(current(r).ID) + reply(w, map[string]bool{"success": true, "reloginRequired": true}) +} + +func (a *App) changeUserPhone(w http.ResponseWriter, r *http.Request) { + var req struct { + Phone string `json:"phone"` + Code string `json:"code"` + CurrentPassword string `json:"currentPassword"` + } + if decode(r, &req) != nil || !validPhone(req.Phone) || len(req.Code) != 6 || req.CurrentPassword == "" { + fail(w, http.StatusBadRequest, 20001, "当前密码、手机号或验证码格式错误") + return + } + var passwordHash string + if a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=? AND deleted_at IS NULL`, current(r).ID).Scan(&passwordHash) != nil || !checkPassword(passwordHash, req.CurrentPassword) { + fail(w, http.StatusUnauthorized, 10001, "当前密码错误") + return + } + if !a.consumeSMSCode(r, req.Phone, "change_phone", req.Code) { + fail(w, http.StatusBadRequest, 20001, "验证码错误或已过期") + return + } + var exists int + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM users WHERE phone_hash=? AND id<>? AND deleted_at IS NULL)`, phoneHash(req.Phone), current(r).ID).Scan(&exists) + if exists == 1 { + fail(w, http.StatusConflict, 20001, "手机号已被其他账号使用") + return + } + cipher, err := a.encryptPhone(req.Phone) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "加密手机号失败") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE users SET phone_hash=?,phone_cipher=? WHERE id=?`, phoneHash(req.Phone), cipher, current(r).ID) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, current(r).ID) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT INTO user_security_controls(user_id,token_version,force_logout_at) VALUES(?,1,NOW(3)) ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=NOW(3)`, current(r).ID) + } + if err != nil || tx.Commit() != nil { + _ = tx.Rollback() + fail(w, http.StatusInternalServerError, 50001, "更换手机号失败") + return + } + a.hub.disconnect(current(r).ID) + reply(w, map[string]bool{"success": true, "reloginRequired": true}) +} + +func (a *App) blockedUsers(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT blocked_user_id FROM user_blocks WHERE user_id=? ORDER BY created_at DESC`, current(r).ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询黑名单失败") + return + } + defer rows.Close() + items := []profileView{} + for rows.Next() { + var id int64 + _ = rows.Scan(&id) + if item, loadErr := a.loadProfile(r, id, current(r).ID); loadErr == nil { + items = append(items, item) + } + } + reply(w, map[string]any{"items": items}) +} + +type notificationSettingsView struct { + IMEnabled bool `json:"imEnabled"` + InteractionEnabled bool `json:"interactionEnabled"` + SystemEnabled bool `json:"systemEnabled"` + SoundEnabled bool `json:"soundEnabled"` + VibrationEnabled bool `json:"vibrationEnabled"` + QuietStart string `json:"quietStart"` + QuietEnd string `json:"quietEnd"` +} + +func (a *App) notificationAllowed(ctx context.Context, userID int64, notificationType string) bool { + var enabled int + column := "interaction_enabled" + if notificationType == "system" { + column = "system_enabled" + } else if notificationType == "im" { + column = "im_enabled" + } + err := a.db.QueryRowContext(ctx, `SELECT `+column+` FROM user_notification_settings WHERE user_id=?`, userID).Scan(&enabled) + return err == sql.ErrNoRows || (err == nil && enabled == 1) +} + +func (a *App) notifyUser(ctx context.Context, userID int64, notificationType, title, content, bizType string, bizID any) { + if userID <= 0 || !a.notificationAllowed(ctx, userID, notificationType) { + return + } + _, _ = a.db.ExecContext(ctx, `INSERT INTO notifications(user_id,type,title,content,biz_type,biz_id) VALUES(?,?,?,?,?,?)`, userID, notificationType, title, content, bizType, bizID) +} + +func (a *App) notificationSettings(w http.ResponseWriter, r *http.Request) { + _, _ = a.db.ExecContext(r.Context(), `INSERT IGNORE INTO user_notification_settings(user_id) VALUES(?)`, current(r).ID) + var item notificationSettingsView + if err := a.db.QueryRowContext(r.Context(), `SELECT im_enabled,interaction_enabled,system_enabled,sound_enabled,vibration_enabled,quiet_start,quiet_end FROM user_notification_settings WHERE user_id=?`, current(r).ID).Scan(&item.IMEnabled, &item.InteractionEnabled, &item.SystemEnabled, &item.SoundEnabled, &item.VibrationEnabled, &item.QuietStart, &item.QuietEnd); err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询通知设置失败") + return + } + reply(w, item) +} + +func validClock(value string) bool { + if value == "" { + return true + } + if len(value) != 5 || value[2] != ':' { + return false + } + hour, hErr := strconv.Atoi(value[:2]) + minute, mErr := strconv.Atoi(value[3:]) + return hErr == nil && mErr == nil && hour >= 0 && hour < 24 && minute >= 0 && minute < 60 +} + +func (a *App) updateNotificationSettings(w http.ResponseWriter, r *http.Request) { + var req notificationSettingsView + if decode(r, &req) != nil || !validClock(req.QuietStart) || !validClock(req.QuietEnd) { + fail(w, http.StatusBadRequest, 20001, "通知设置格式错误") + return + } + _, err := a.db.ExecContext(r.Context(), `INSERT INTO user_notification_settings(user_id,im_enabled,interaction_enabled,system_enabled,sound_enabled,vibration_enabled,quiet_start,quiet_end) VALUES(?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE im_enabled=VALUES(im_enabled),interaction_enabled=VALUES(interaction_enabled),system_enabled=VALUES(system_enabled),sound_enabled=VALUES(sound_enabled),vibration_enabled=VALUES(vibration_enabled),quiet_start=VALUES(quiet_start),quiet_end=VALUES(quiet_end)`, current(r).ID, req.IMEnabled, req.InteractionEnabled, req.SystemEnabled, req.SoundEnabled, req.VibrationEnabled, req.QuietStart, req.QuietEnd) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存通知设置失败") + return + } + reply(w, req) +} + +func (a *App) registerPushToken(w http.ResponseWriter, r *http.Request) { + var req struct { + DeviceID string `json:"deviceId"` + Provider string `json:"provider"` + PushToken string `json:"pushToken"` + Platform string `json:"platform"` + AppVersion string `json:"appVersion"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.DeviceID) == "" || strings.TrimSpace(req.Provider) == "" || strings.TrimSpace(req.PushToken) == "" || len(req.PushToken) > 500 { + fail(w, http.StatusBadRequest, 20001, "推送设备信息不完整") + return + } + _, err := a.db.ExecContext(r.Context(), `INSERT INTO user_push_tokens(user_id,device_id,provider,push_token,platform,app_version,status,last_active_at) VALUES(?,?,?,?,?,?,1,NOW(3)) ON DUPLICATE KEY UPDATE push_token=VALUES(push_token),platform=VALUES(platform),app_version=VALUES(app_version),status=1,last_active_at=NOW(3)`, current(r).ID, req.DeviceID, req.Provider, req.PushToken, req.Platform, req.AppVersion) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存推送设备失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) deletePushToken(w http.ResponseWriter, r *http.Request) { + deviceID := strings.TrimSpace(r.URL.Query().Get("deviceId")) + if deviceID == "" { + fail(w, http.StatusBadRequest, 20001, "设备标识不能为空") + return + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE user_push_tokens SET status=0 WHERE user_id=? AND device_id=?`, current(r).ID, deviceID) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) feedback(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,category,content,contact,evidence_json,status,reply_content,created_at,updated_at FROM user_feedback WHERE user_id=? ORDER BY created_at DESC LIMIT 100`, current(r).ID) + if err != nil { + fail(w, 500, 50001, "查询反馈失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var category, content, contact, evidenceJSON, status, reply string + var created, updated time.Time + if rows.Scan(&id, &category, &content, &contact, &evidenceJSON, &status, &reply, &created, &updated) == nil { + var evidence []string + _ = json.Unmarshal([]byte(evidenceJSON), &evidence) + items = append(items, map[string]any{"id": id, "category": category, "content": content, "contact": contact, "evidence": evidence, "status": status, "reply": reply, "createdAt": created, "updatedAt": updated}) + } + } + reply(w, map[string]any{"items": items}) + return + } + var req struct { + Category string `json:"category"` + Content string `json:"content"` + Contact string `json:"contact"` + Evidence []string `json:"evidence"` + } + req.Category = strings.ToLower(strings.TrimSpace(req.Category)) + if decode(r, &req) != nil { + fail(w, 400, 20001, "反馈格式错误") + return + } + req.Category = strings.ToLower(strings.TrimSpace(req.Category)) + req.Content = strings.TrimSpace(req.Content) + allowed := map[string]bool{"bug": true, "suggestion": true, "complaint": true, "other": true} + if !allowed[req.Category] || len([]rune(req.Content)) < 5 || len([]rune(req.Content)) > 2000 || len(req.Evidence) > 6 { + fail(w, http.StatusBadRequest, 20001, "请选择反馈类型并填写 5-2000 字内容") + return + } + evidence, evidenceErr := a.validateOwnedImageEvidence(r.Context(), current(r).ID, req.Evidence) + if evidenceErr != nil { + fail(w, http.StatusBadRequest, 20001, evidenceErr.Error()) + return + } + evidenceJSON, _ := json.Marshal(evidence) + result, err := a.db.ExecContext(r.Context(), `INSERT INTO user_feedback(user_id,category,content,contact,evidence_json) VALUES(?,?,?,?,?)`, current(r).ID, req.Category, req.Content, strings.TrimSpace(req.Contact), evidenceJSON) + if err != nil { + fail(w, 500, 50001, "提交反馈失败") + return + } + id, _ := result.LastInsertId() + reply(w, map[string]any{"id": id, "status": "PENDING"}) +} + +func (a *App) accountClosure(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + var status, reason string + var requested, executeAfter time.Time + var cancelled, completed sql.NullTime + err := a.db.QueryRowContext(r.Context(), `SELECT status,reason,requested_at,execute_after,cancelled_at,completed_at FROM user_account_closures WHERE user_id=?`, current(r).ID).Scan(&status, &reason, &requested, &executeAfter, &cancelled, &completed) + if err == sql.ErrNoRows { + reply(w, map[string]any{"status": "NONE"}) + return + } + if err != nil { + fail(w, 500, 50001, "查询注销状态失败") + return + } + reply(w, map[string]any{"status": status, "reason": reason, "requestedAt": requested, "executeAfter": executeAfter, "cancelledAt": nullableTime(cancelled), "completedAt": nullableTime(completed)}) + return + } + if r.Method == http.MethodDelete { + result, err := a.db.ExecContext(r.Context(), `UPDATE user_account_closures SET status='CANCELLED',cancelled_at=NOW(3) WHERE user_id=? AND status='PENDING' AND execute_after>NOW(3)`, current(r).ID) + if err != nil { + fail(w, 500, 50001, "取消注销失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 409, 20001, "当前没有可取消的注销申请") + return + } + reply(w, map[string]bool{"success": true}) + return + } + var req struct { + Password string `json:"password"` + Reason string `json:"reason"` + } + if decode(r, &req) != nil || req.Password == "" { + fail(w, 400, 20001, "请输入当前密码确认注销") + return + } + var passwordHash string + if a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, current(r).ID).Scan(&passwordHash) != nil || !checkPassword(passwordHash, req.Password) { + fail(w, 401, 10001, "当前密码错误") + return + } + days, _ := strconv.Atoi(a.configPlain(r.Context(), "account.cancellation_cooling_days", "7")) + if days < 1 { + days = 7 + } + _, err := a.db.ExecContext(r.Context(), `INSERT INTO user_account_closures(user_id,reason,status,requested_at,execute_after,cancelled_at,completed_at) VALUES(?,?,'PENDING',NOW(3),DATE_ADD(NOW(3),INTERVAL ? DAY),NULL,NULL) ON DUPLICATE KEY UPDATE reason=VALUES(reason),status='PENDING',requested_at=NOW(3),execute_after=VALUES(execute_after),cancelled_at=NULL,completed_at=NULL`, current(r).ID, strings.TrimSpace(req.Reason), days) + if err != nil { + fail(w, 500, 50001, "提交注销申请失败") + return + } + reply(w, map[string]any{"status": "PENDING", "coolingDays": days}) +} + +func (a *App) executeDueAccountClosureContext(ctx context.Context, userID int64) bool { + tx, err := a.db.BeginTx(ctx, nil) + if err != nil { + return false + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(ctx, `UPDATE user_account_closures SET status='COMPLETED',completed_at=NOW(3) WHERE user_id=? AND status='PENDING' AND execute_after<=NOW(3)`, userID) + if err != nil { + return false + } + affected, _ := result.RowsAffected() + if affected == 0 { + return false + } + _, err = tx.ExecContext(ctx, `UPDATE users SET status=0,deleted_at=NOW(3),phone_hash=NULL,phone_cipher=NULL,password_hash='' WHERE id=?`, userID) + if err == nil { + _, err = tx.ExecContext(ctx, `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `UPDATE user_profiles SET nickname='已注销用户',avatar_url='',cover_url='',gender=0,birthday=NULL,height_cm=NULL,city_code='',city_name='',occupation='',education=0,relationship_status=0,bio='',is_vip=0,vip_level=0,last_active_at=NULL WHERE user_id=?`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `UPDATE posts SET status=0,deleted_at=COALESCE(deleted_at,NOW(3)) WHERE user_id=?`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `DELETE FROM user_location_states WHERE user_id=?`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `DELETE FROM user_push_tokens WHERE user_id=?`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `DELETE FROM user_oauth_identities WHERE user_id=?`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `DELETE FROM user_verifications WHERE user_id=?`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `UPDATE user_feedback SET contact='',evidence_json=JSON_ARRAY() WHERE user_id=?`, userID) + } + if err != nil || tx.Commit() != nil { + return false + } + a.hub.disconnect(userID) + return true +} + +func (a *App) executeDueAccountClosure(r *http.Request, userID int64) bool { + return a.executeDueAccountClosureContext(r.Context(), userID) +} + +func (a *App) processDueAccountClosures(ctx context.Context) { + rows, err := a.db.QueryContext(ctx, `SELECT user_id FROM user_account_closures WHERE status='PENDING' AND execute_after<=NOW(3) ORDER BY execute_after LIMIT 100`) + if err != nil { + return + } + ids := []int64{} + for rows.Next() { + var id int64 + if rows.Scan(&id) == nil { + ids = append(ids, id) + } + } + _ = rows.Close() + for _, id := range ids { + a.executeDueAccountClosureContext(ctx, id) + } +} + +func (a *App) exportMyData(w http.ResponseWriter, r *http.Request) { + profile, err := a.loadProfile(r, current(r).ID, current(r).ID) + if err != nil { + fail(w, 404, 30001, "用户不存在") + return + } + var privacy privacyView + var p [8]int + _ = a.db.QueryRowContext(r.Context(), `SELECT nearby_visible,distance_visible,online_visible,last_active_visible,allow_stranger_message,allow_profile_visit_record,allow_search,invisible_visit FROM user_privacy_settings WHERE user_id=?`, current(r).ID).Scan(&p[0], &p[1], &p[2], &p[3], &p[4], &p[5], &p[6], &p[7]) + privacy = privacyView{NearbyVisible: p[0] == 1, DistanceVisible: p[1] == 1, OnlineVisible: p[2] == 1, LastActiveVisible: p[3] == 1, AllowStrangerMessage: p[4] == 1, AllowProfileVisitRecord: p[5] == 1, AllowSearch: p[6] == 1, InvisibleVisit: p[7] == 1} + var posts, messages, orders int + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM posts WHERE user_id=? AND deleted_at IS NULL`, current(r).ID).Scan(&posts) + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM im_messages WHERE sender_id=?`, current(r).ID).Scan(&messages) + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM orders WHERE user_id=? AND deleted_at IS NULL`, current(r).ID).Scan(&orders) + reply(w, map[string]any{"exportedAt": time.Now(), "profile": profile, "privacy": privacy, "statistics": map[string]int{"posts": posts, "messages": messages, "orders": orders}, "notice": "聊天内容、订单明细和认证材料涉及敏感信息,请通过客服完成加密归档导出。"}) +} + +func (a *App) recordConsent(w http.ResponseWriter, r *http.Request) { + var req struct { + Type string `json:"type"` + Version string `json:"version"` + DeviceID string `json:"deviceId"` + } + if decode(r, &req) != nil || (req.Type != "user_agreement" && req.Type != "privacy_policy") || strings.TrimSpace(req.Version) == "" { + fail(w, 400, 20001, "协议确认信息无效") + return + } + _, err := a.db.ExecContext(r.Context(), `INSERT IGNORE INTO user_consents(user_id,agreement_type,agreement_version,ip,device_id) VALUES(?,?,?,?,?)`, current(r).ID, req.Type, strings.TrimSpace(req.Version), clientIP(r), strings.TrimSpace(req.DeviceID)) + if err != nil { + fail(w, 500, 50001, "保存协议确认失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) updatePost(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态编号无效") + return + } + var req struct { + Content string `json:"content"` + Visibility int `json:"visibility"` + Location string `json:"location"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.Content) == "" || len([]rune(req.Content)) > 2000 { + fail(w, 400, 20001, "动态内容需为 1-2000 字") + return + } + if req.Visibility != 2 { + req.Visibility = 1 + } + result, err := a.db.ExecContext(r.Context(), `UPDATE posts SET content=?,visibility=?,location_text=?,moderation_status=1 WHERE id=? AND user_id=? AND status=1`, strings.TrimSpace(req.Content), req.Visibility, strings.TrimSpace(req.Location), id, current(r).ID) + if err != nil { + fail(w, 500, 50001, "修改动态失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 404, 30001, "动态不存在或无权修改") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) deleteOwnPost(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE posts SET status=0,deleted_at=NOW(3) WHERE id=? AND user_id=? AND status=1`, id, current(r).ID) + if err != nil { + fail(w, 500, 50001, "删除动态失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 404, 30001, "动态不存在或无权删除") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) deleteOwnComment(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "评论编号无效") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "删除评论失败") + return + } + defer func() { _ = tx.Rollback() }() + var postID int64 + if err = tx.QueryRowContext(r.Context(), `SELECT c.post_id FROM post_comments c JOIN posts p ON p.id=c.post_id WHERE c.id=? AND (c.user_id=? OR p.user_id=?) AND c.status=1 FOR UPDATE`, id, current(r).ID, current(r).ID).Scan(&postID); err != nil { + fail(w, 404, 30001, "评论不存在或无权删除") + return + } + _, err = tx.ExecContext(r.Context(), `UPDATE post_comments SET status=0,deleted_at=NOW(3) WHERE id=?`, id) + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE posts SET comment_count=GREATEST(comment_count-1,0) WHERE id=?`, postID) + } + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "删除评论失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) readNotification(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "通知编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE notifications SET read_at=COALESCE(read_at,NOW(3)) WHERE id=? AND user_id=?`, id, current(r).ID) + if err != nil { + fail(w, 500, 50001, "更新通知失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 404, 30001, "通知不存在") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) myReports(w http.ResponseWriter, r *http.Request) { + page, pageSize, offset := pageOptions(r) + var total int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM reports WHERE reporter_user_id=?`, current(r).ID).Scan(&total); err != nil { + fail(w, 500, 50001, "查询举报记录失败") + return + } + rows, err := a.db.QueryContext(r.Context(), `SELECT id,target_type,target_id,reason_code,description,evidence_json,status,action_type,handle_remark,created_at,handled_at FROM reports WHERE reporter_user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?`, current(r).ID, pageSize, offset) + if err != nil { + fail(w, 500, 50001, "查询举报记录失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, targetID int64 + var typ, reason, description, evidenceJSON, status, actionType, handleRemark string + var created time.Time + var handled sql.NullTime + if rows.Scan(&id, &typ, &targetID, &reason, &description, &evidenceJSON, &status, &actionType, &handleRemark, &created, &handled) == nil { + var evidence []string + _ = json.Unmarshal([]byte(evidenceJSON), &evidence) + items = append(items, map[string]any{"id": id, "targetType": typ, "targetId": targetID, "reason": reason, "description": description, "evidence": evidence, "status": status, "actionType": actionType, "handleRemark": handleRemark, "createdAt": created, "handledAt": nullableTime(handled)}) + } + } + reply(w, map[string]any{"items": items, "page": page, "pageSize": pageSize, "total": total, "hasMore": offset+len(items) < total}) +} + +func (a *App) closeOwnOrder(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "订单编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE orders SET status='CLOSED' WHERE id=? AND user_id=? AND status='CREATED' AND deleted_at IS NULL`, id, current(r).ID) + if err != nil { + fail(w, 500, 50001, "关闭订单失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 409, 20001, "当前订单状态无法关闭") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) requestOrderRefund(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "订单编号无效") + return + } + var req struct { + Reason string `json:"reason"` + } + if decode(r, &req) != nil || len([]rune(strings.TrimSpace(req.Reason))) < 2 { + fail(w, 400, 20001, "请填写退款原因") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE orders SET status='REFUND_REQUESTED',refund_reason=?,refund_requested_at=NOW(3) WHERE id=? AND user_id=? AND status='PAID' AND deleted_at IS NULL`, strings.TrimSpace(req.Reason), id, current(r).ID) + if err != nil { + fail(w, 500, 50001, "提交退款申请失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 409, 20001, "当前订单状态无法申请退款") + return + } + reply(w, map[string]bool{"success": true}) +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/client_productization_test.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/client_productization_test.go new file mode 100644 index 0000000..50a640a --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/client_productization_test.go @@ -0,0 +1,35 @@ +package app + +import "testing" + +func TestValidClock(t *testing.T) { + tests := map[string]bool{ + "": true, + "00:00": true, + "09:30": true, + "23:59": true, + "24:00": false, + "12:60": false, + "9:30": false, + "noon": false, + } + for value, expected := range tests { + if actual := validClock(value); actual != expected { + t.Fatalf("validClock(%q) = %v, want %v", value, actual, expected) + } + } +} + +func TestOrderEntitlementActive(t *testing.T) { + active := []string{"PAID", "REFUND_REQUESTED", "REFUNDING"} + for _, status := range active { + if !orderEntitlementActive(status) { + t.Fatalf("status %s must keep the membership entitlement active", status) + } + } + for _, status := range []string{"CREATED", "REFUNDED", "CLOSED", "UNKNOWN"} { + if orderEntitlementActive(status) { + t.Fatalf("status %s must not grant a membership entitlement", status) + } + } +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/extensions.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/extensions.go new file mode 100644 index 0000000..15e3642 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/extensions.go @@ -0,0 +1,244 @@ +package app + +import ( + "database/sql" + "net/http" + "strings" +) + +func (a *App) searchUsers(w http.ResponseWriter, r *http.Request) { + keyword := strings.TrimSpace(r.URL.Query().Get("q")) + if keyword == "" { + reply(w, map[string]any{"items": []profileView{}, "total": 0}) + return + } + pattern := "%" + keyword + "%" + page, pageSize, offset := pageOptions(r) + var total int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users u JOIN user_profiles p ON p.user_id=u.id JOIN user_privacy_settings privacy ON privacy.user_id=u.id WHERE u.status=1 AND u.id<>? AND privacy.allow_search=1 AND (p.nickname LIKE ? OR u.public_id LIKE ?) AND NOT EXISTS(SELECT 1 FROM user_blocks b WHERE (b.user_id=? AND b.blocked_user_id=u.id) OR (b.user_id=u.id AND b.blocked_user_id=?))`, current(r).ID, pattern, pattern, current(r).ID, current(r).ID).Scan(&total); err != nil { + fail(w, 500, 50001, "搜索失败") + return + } + rows, err := a.db.QueryContext(r.Context(), `SELECT u.id FROM users u JOIN user_profiles p ON p.user_id=u.id JOIN user_privacy_settings privacy ON privacy.user_id=u.id WHERE u.status=1 AND u.id<>? AND privacy.allow_search=1 AND (p.nickname LIKE ? OR u.public_id LIKE ?) AND NOT EXISTS(SELECT 1 FROM user_blocks b WHERE (b.user_id=? AND b.blocked_user_id=u.id) OR (b.user_id=u.id AND b.blocked_user_id=?)) ORDER BY p.is_vip DESC,p.last_active_at DESC LIMIT ? OFFSET ?`, current(r).ID, pattern, pattern, current(r).ID, current(r).ID, pageSize, offset) + if err != nil { + fail(w, 500, 50001, "搜索失败") + return + } + defer rows.Close() + items := []profileView{} + for rows.Next() { + var id int64 + _ = rows.Scan(&id) + if item, loadErr := a.loadProfile(r, id, current(r).ID); loadErr == nil { + items = append(items, item) + } + } + reply(w, map[string]any{"items": items, "total": total, "keyword": keyword, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) +} + +func (a *App) followingList(w http.ResponseWriter, r *http.Request) { + a.relationshipList(w, r, "following") +} + +func (a *App) followerList(w http.ResponseWriter, r *http.Request) { + a.relationshipList(w, r, "followers") +} + +func (a *App) visitorList(w http.ResponseWriter, r *http.Request) { + if !a.resolveMembershipEntitlements(r.Context(), current(r).ID).CanViewVisitors { + fail(w, http.StatusForbidden, 10006, "开通会员后可查看访客记录") + return + } + a.relationshipList(w, r, "visitors") +} + +func (a *App) relationshipList(w http.ResponseWriter, r *http.Request, listType string) { + var rows *sql.Rows + var err error + page, pageSize, offset := pageOptions(r) + var total int + switch listType { + case "following": + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_follows WHERE user_id=?`, current(r).ID).Scan(&total) + rows, err = a.db.QueryContext(r.Context(), `SELECT target_user_id FROM user_follows WHERE user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?`, current(r).ID, pageSize, offset) + case "followers": + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_follows WHERE target_user_id=?`, current(r).ID).Scan(&total) + rows, err = a.db.QueryContext(r.Context(), `SELECT user_id FROM user_follows WHERE target_user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?`, current(r).ID, pageSize, offset) + default: + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(DISTINCT viewer_user_id) FROM profile_visits WHERE target_user_id=?`, current(r).ID).Scan(&total) + rows, err = a.db.QueryContext(r.Context(), `SELECT viewer_user_id FROM profile_visits WHERE target_user_id=? GROUP BY viewer_user_id ORDER BY MAX(visited_at) DESC LIMIT ? OFFSET ?`, current(r).ID, pageSize, offset) + } + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []profileView{} + for rows.Next() { + var id int64 + _ = rows.Scan(&id) + if item, loadErr := a.loadProfile(r, id, current(r).ID); loadErr == nil { + items = append(items, item) + } + } + reply(w, map[string]any{"items": items, "total": total, "type": listType, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) +} + +type privacyView struct { + NearbyVisible bool `json:"nearbyVisible"` + DistanceVisible bool `json:"distanceVisible"` + OnlineVisible bool `json:"onlineVisible"` + LastActiveVisible bool `json:"lastActiveVisible"` + AllowStrangerMessage bool `json:"allowStrangerMessage"` + AllowProfileVisitRecord bool `json:"allowProfileVisitRecord"` + InvisibleVisit bool `json:"invisibleVisit"` + AllowSearch bool `json:"allowSearch"` +} + +func (a *App) getPrivacy(w http.ResponseWriter, r *http.Request) { + var values [8]int + err := a.db.QueryRowContext(r.Context(), `SELECT nearby_visible,distance_visible,online_visible,last_active_visible,allow_stranger_message,allow_profile_visit_record,invisible_visit,allow_search FROM user_privacy_settings WHERE user_id=?`, current(r).ID).Scan(&values[0], &values[1], &values[2], &values[3], &values[4], &values[5], &values[6], &values[7]) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + reply(w, privacyView{NearbyVisible: values[0] == 1, DistanceVisible: values[1] == 1, OnlineVisible: values[2] == 1, LastActiveVisible: values[3] == 1, AllowStrangerMessage: values[4] == 1, AllowProfileVisitRecord: values[5] == 1, InvisibleVisit: values[6] == 1, AllowSearch: values[7] == 1}) +} + +func (a *App) updatePrivacy(w http.ResponseWriter, r *http.Request) { + var req privacyView + if decode(r, &req) != nil { + fail(w, 400, 20001, "隐私设置格式错误") + return + } + if req.InvisibleVisit && !a.resolveMembershipEntitlements(r.Context(), current(r).ID).CanInvisibleVisit { + fail(w, http.StatusForbidden, 10006, "当前会员等级不支持隐身访问") + return + } + _, err := a.db.ExecContext(r.Context(), `UPDATE user_privacy_settings SET nearby_visible=?,distance_visible=?,online_visible=?,last_active_visible=?,allow_stranger_message=?,allow_profile_visit_record=?,invisible_visit=?,allow_search=? WHERE user_id=?`, req.NearbyVisible, req.DistanceVisible, req.OnlineVisible, req.LastActiveVisible, req.AllowStrangerMessage, req.AllowProfileVisitRecord, req.InvisibleVisit, req.AllowSearch, current(r).ID) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + reply(w, req) +} + +func (a *App) blockUser(w http.ResponseWriter, r *http.Request) { + target, err := pathID(r) + if err != nil || target == current(r).ID { + fail(w, 400, 20001, "无效用户") + return + } + var targetExists int + if err = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM users WHERE id=? AND status=1 AND deleted_at IS NULL)`, target).Scan(&targetExists); err != nil || targetExists != 1 { + fail(w, http.StatusNotFound, 30001, "用户不存在") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "拉黑失败") + return + } + defer tx.Rollback() + _, err = tx.ExecContext(r.Context(), `INSERT IGNORE INTO user_blocks(user_id,blocked_user_id,reason)VALUES(?,?,'user_action')`, current(r).ID, target) + if err == nil { + _, err = tx.ExecContext(r.Context(), `DELETE FROM user_follows WHERE (user_id=? AND target_user_id=?) OR (user_id=? AND target_user_id=?)`, current(r).ID, target, target, current(r).ID) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `DELETE FROM user_likes WHERE (user_id=? AND target_user_id=?) OR (user_id=? AND target_user_id=?)`, current(r).ID, target, target, current(r).ID) + } + if err == nil { + err = tx.Commit() + } + if err != nil { + fail(w, 500, 50001, "拉黑失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) unblockUser(w http.ResponseWriter, r *http.Request) { + target, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "无效用户") + return + } + _, err = a.db.ExecContext(r.Context(), `DELETE FROM user_blocks WHERE user_id=? AND blocked_user_id=?`, current(r).ID, target) + if err != nil { + fail(w, 500, 50001, "解除拉黑失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) loadPost(r *http.Request, id int64) (postView, error) { + var item postView + var userID int64 + var vip int + err := a.db.QueryRowContext(r.Context(), `SELECT p.id,p.user_id,p.content,p.location_text,p.like_count,p.comment_count,p.created_at,u.public_id,pr.nickname,pr.avatar_url,pr.gender,pr.is_vip,EXISTS(SELECT 1 FROM post_likes l WHERE l.post_id=p.id AND l.user_id=?) FROM posts p JOIN users u ON u.id=p.user_id JOIN user_profiles pr ON pr.user_id=p.user_id WHERE p.id=? AND p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id)))`, current(r).ID, id, current(r).ID, current(r).ID).Scan(&item.ID, &userID, &item.Content, &item.Location, &item.LikeCount, &item.CommentCount, &item.CreatedAt, &item.User.PublicID, &item.User.Nickname, &item.User.Avatar, &item.User.Gender, &vip, &item.Liked) + if err != nil { + return item, err + } + item.User.ID = userID + item.User.VIP = vip == 1 + item.CanEdit = userID == current(r).ID + item.CanDelete = userID == current(r).ID + item.Media = []string{} + rows, _ := a.db.QueryContext(r.Context(), `SELECT media_url FROM post_media WHERE post_id=? ORDER BY sort_order`, id) + if rows != nil { + defer rows.Close() + for rows.Next() { + var media string + _ = rows.Scan(&media) + item.Media = append(item.Media, media) + } + } + return item, nil +} + +func (a *App) postDetail(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态编号无效") + return + } + item, err := a.loadPost(r, id) + if err != nil { + fail(w, 404, 30001, "动态不存在") + return + } + reply(w, item) +} + +func (a *App) userPosts(w http.ResponseWriter, r *http.Request) { + userID, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "用户编号无效") + return + } + page, pageSize, offset := pageOptions(r) + var total int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM posts WHERE user_id=? AND status=1 AND (visibility=1 OR user_id=? OR (visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=posts.user_id)))`, userID, current(r).ID, current(r).ID).Scan(&total); err != nil { + fail(w, 500, 50001, "查询失败") + return + } + rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM posts WHERE user_id=? AND status=1 AND (visibility=1 OR user_id=? OR (visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=posts.user_id))) ORDER BY created_at DESC LIMIT ? OFFSET ?`, userID, current(r).ID, current(r).ID, pageSize, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + ids := []int64{} + for rows.Next() { + var id int64 + _ = rows.Scan(&id) + ids = append(ids, id) + } + items := []postView{} + for _, id := range ids { + if item, loadErr := a.loadPost(r, id); loadErr == nil { + items = append(items, item) + } + } + reply(w, map[string]any{"items": items, "total": total, "userId": userID, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/im.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/im.go new file mode 100644 index 0000000..a284f17 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/im.go @@ -0,0 +1,705 @@ +package app + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/gorilla/websocket" +) + +type messageView struct { + ID int64 `json:"id"` + ConversationID int64 `json:"conversationId"` + Seq int64 `json:"seq"` + SenderID int64 `json:"senderId"` + ClientMsgID string `json:"clientMsgId"` + Type int `json:"type"` + Content any `json:"content"` + Recalled bool `json:"recalled"` + CreatedAt time.Time `json:"createdAt"` +} + +const ( + maxMessageBodyBytes = 16 << 10 + maxMessageTextRunes = 2000 +) + +var clientMessageIDPattern = regexp.MustCompile(`^[A-Za-z0-9_.:-]{1,64}$`) + +func validMessageMediaURL(raw string, production bool) bool { + raw = strings.TrimSpace(raw) + if raw == "" || len(raw) > 2048 { + return false + } + if strings.HasPrefix(raw, "/uploads/") { + return !strings.Contains(raw, "..") + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return false + } + return !production || parsed.Scheme == "https" +} + +func numericDuration(value any) (int, bool) { + switch duration := value.(type) { + case float64: + return int(duration), duration == float64(int(duration)) + case int: + return duration, true + case int64: + return int(duration), true + case json.Number: + parsed, err := strconv.Atoi(duration.String()) + return parsed, err == nil + default: + return 0, false + } +} + +func (a *App) validateMessagePayload(clientMsgID string, messageType int, content any) (string, any, []byte, error) { + clientMsgID = strings.TrimSpace(clientMsgID) + if clientMsgID == "" { + clientMsgID = randomToken()[:26] + } + if !clientMessageIDPattern.MatchString(clientMsgID) { + return "", nil, nil, fmt.Errorf("客户端消息 ID 格式无效") + } + if messageType == 0 { + messageType = 1 + } + data, ok := content.(map[string]any) + if !ok { + return "", nil, nil, fmt.Errorf("消息内容格式错误") + } + switch messageType { + case 1: + text, ok := data["text"].(string) + text = strings.TrimSpace(text) + if !ok || text == "" || !utf8.ValidString(text) || utf8.RuneCountInString(text) > maxMessageTextRunes { + return "", nil, nil, fmt.Errorf("文本消息应为 1 至 %d 个字符", maxMessageTextRunes) + } + data = map[string]any{"text": text} + case 2: + mediaURL, ok := data["url"].(string) + if !ok || !validMessageMediaURL(mediaURL, a.config.Environment == "production") { + return "", nil, nil, fmt.Errorf("图片地址无效") + } + data = map[string]any{"url": strings.TrimSpace(mediaURL)} + case 3: + mediaURL, ok := data["url"].(string) + duration, durationOK := numericDuration(data["duration"]) + if !ok || !validMessageMediaURL(mediaURL, a.config.Environment == "production") || !durationOK || duration < 1 || duration > 60 { + return "", nil, nil, fmt.Errorf("语音消息地址或时长无效") + } + data = map[string]any{"duration": duration, "url": strings.TrimSpace(mediaURL)} + default: + return "", nil, nil, fmt.Errorf("不支持的消息类型") + } + body, err := json.Marshal(data) + if err != nil || len(body) > maxMessageBodyBytes { + return "", nil, nil, fmt.Errorf("消息内容过大") + } + return clientMsgID, data, body, nil +} + +type wsClient struct { + userID int64 + conn *websocket.Conn + mu sync.Mutex +} + +func (c *wsClient) writeJSON(payload any) error { + c.mu.Lock() + defer c.mu.Unlock() + _ = c.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + return c.conn.WriteJSON(payload) +} + +type Hub struct { + mu sync.RWMutex + clients map[int64]map[*wsClient]struct{} +} + +func NewHub() *Hub { return &Hub{clients: make(map[int64]map[*wsClient]struct{})} } + +func (h *Hub) add(client *wsClient) { + h.mu.Lock() + defer h.mu.Unlock() + if h.clients[client.userID] == nil { + h.clients[client.userID] = make(map[*wsClient]struct{}) + } + h.clients[client.userID][client] = struct{}{} +} + +func (h *Hub) remove(client *wsClient) { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.clients[client.userID], client) + if len(h.clients[client.userID]) == 0 { + delete(h.clients, client.userID) + } +} + +func (h *Hub) broadcast(userIDs []int64, payload any) { + h.mu.RLock() + targets := []*wsClient{} + for _, userID := range userIDs { + for client := range h.clients[userID] { + targets = append(targets, client) + } + } + h.mu.RUnlock() + for _, client := range targets { + _ = client.writeJSON(payload) + } +} + +func (h *Hub) disconnect(userID int64) { + h.mu.RLock() + targets := []*wsClient{} + for client := range h.clients[userID] { + targets = append(targets, client) + } + h.mu.RUnlock() + for _, client := range targets { + client.mu.Lock() + _ = client.conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "登录状态已失效"), time.Now().Add(time.Second)) + _ = client.conn.Close() + client.mu.Unlock() + } +} + +func (a *App) directConversation(w http.ResponseWriter, r *http.Request) { + var req struct { + UserID int64 `json:"userId"` + } + if decode(r, &req) != nil || req.UserID == 0 || req.UserID == current(r).ID { + fail(w, 400, 20001, "无效的聊天对象") + return + } + first, second := current(r).ID, req.UserID + if first > second { + first, second = second, first + } + var targetStatus, allowStranger int + if err := a.db.QueryRowContext(r.Context(), `SELECT u.status,p.allow_stranger_message FROM users u JOIN user_privacy_settings p ON p.user_id=u.id WHERE u.id=? AND u.deleted_at IS NULL`, req.UserID).Scan(&targetStatus, &allowStranger); err != nil || targetStatus != 1 { + fail(w, http.StatusNotFound, 30001, "聊天对象不存在或不可用") + return + } + var blocked int + if err := a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM user_blocks WHERE (user_id=? AND blocked_user_id=?) OR (user_id=? AND blocked_user_id=?))`, current(r).ID, req.UserID, req.UserID, current(r).ID).Scan(&blocked); err != nil || blocked == 1 { + fail(w, http.StatusForbidden, 30002, "当前无法与该用户聊天") + return + } + var id int64 + err := a.db.QueryRowContext(r.Context(), `SELECT conversation_id FROM im_direct_conversations WHERE user1_id=? AND user2_id=?`, first, second).Scan(&id) + if err == nil { + reply(w, map[string]any{"id": id}) + return + } + if allowStranger == 0 { + var mutualFollow int + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS( + SELECT 1 FROM user_follows mine JOIN user_follows target + ON target.user_id=mine.target_user_id AND target.target_user_id=mine.user_id + WHERE mine.user_id=? AND mine.target_user_id=?)`, current(r).ID, req.UserID).Scan(&mutualFollow) + if mutualFollow != 1 { + fail(w, http.StatusForbidden, 30002, "对方仅允许互相关注的人发起私信") + return + } + } + tx, beginErr := a.db.BeginTx(r.Context(), nil) + if beginErr != nil { + fail(w, http.StatusInternalServerError, 50001, "创建会话失败") + return + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(r.Context(), `INSERT INTO im_conversations(conversation_type)VALUES(1)`) + if err != nil { + _ = tx.Rollback() + // Concurrent requests may have created the same unique direct pair. + if a.db.QueryRowContext(r.Context(), `SELECT conversation_id FROM im_direct_conversations WHERE user1_id=? AND user2_id=?`, first, second).Scan(&id) == nil { + reply(w, map[string]any{"id": id}) + return + } + fail(w, 500, 50001, "创建会话失败") + return + } + id, _ = result.LastInsertId() + _, err = tx.ExecContext(r.Context(), `INSERT INTO im_direct_conversations(conversation_id,user1_id,user2_id)VALUES(?,?,?)`, id, first, second) + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT INTO im_conversation_members(conversation_id,user_id)VALUES(?,?),(?,?)`, id, first, id, second) + } + if err != nil { + _ = tx.Rollback() + fail(w, 500, 50001, "创建会话失败") + return + } + _ = tx.Commit() + reply(w, map[string]any{"id": id}) +} + +func (a *App) conversations(w http.ResponseWriter, r *http.Request) { + who := current(r) + rows, err := a.db.QueryContext(r.Context(), `SELECT c.id,c.last_seq,c.last_message_at,m.read_seq,m.pinned,m.muted, + other.user_id,p.nickname,p.avatar_url,p.is_vip,p.last_active_at,privacy.online_visible,COALESCE(CAST(msg.body AS CHAR CHARACTER SET utf8mb4),''),msg.recalled_at,msg.admin_removed_at + FROM im_conversation_members m JOIN im_conversations c ON c.id=m.conversation_id + JOIN im_conversation_members other ON other.conversation_id=c.id AND other.user_id<>m.user_id + JOIN user_profiles p ON p.user_id=other.user_id JOIN user_privacy_settings privacy ON privacy.user_id=other.user_id LEFT JOIN im_messages msg ON msg.id=c.last_message_id + WHERE m.user_id=? AND m.status=1 ORDER BY m.pinned DESC,c.last_message_at DESC`, who.ID) + if err != nil { + fail(w, 500, 50001, err.Error()) + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, lastSeq, readSeq, otherID int64 + var lastAt sql.NullTime + var pinned, muted, vip int + var nick, avatar, body string + var active sql.NullTime + var recalledAt, adminRemovedAt sql.NullTime + var onlineVisible int + _ = rows.Scan(&id, &lastSeq, &lastAt, &readSeq, &pinned, &muted, &otherID, &nick, &avatar, &vip, &active, &onlineVisible, &body, &recalledAt, &adminRemovedAt) + preview := "开始聊天吧" + var content map[string]any + if recalledAt.Valid || adminRemovedAt.Valid { + preview = "消息已撤回" + } else if json.Unmarshal([]byte(body), &content) == nil { + if text, ok := content["text"].(string); ok { + preview = text + } + } + items = append(items, map[string]any{"id": id, "lastSeq": lastSeq, "unread": max64(lastSeq-readSeq, 0), "lastMessageAt": lastAt, "pinned": pinned == 1, "muted": muted == 1, "lastMessage": preview, "user": map[string]any{"id": otherID, "nickname": nick, "avatar": avatar, "vip": vip == 1, "online": onlineVisible == 1 && active.Valid && time.Since(active.Time) < 15*time.Minute}}) + } + reply(w, map[string]any{"items": items, "total": len(items)}) +} + +func (a *App) messages(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "invalid conversation") + return + } + if !a.isMember(r, id, current(r).ID) { + fail(w, 403, 30002, "不是会话成员") + return + } + beforeSeq, _ := strconv.ParseInt(r.URL.Query().Get("beforeSeq"), 10, 64) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 || limit > 100 { + limit = 50 + } + query := `SELECT id,conversation_id,seq,sender_id,client_msg_id,message_type,body,recalled_at,admin_removed_at,created_at FROM im_messages WHERE conversation_id=?` + args := []any{id} + if beforeSeq > 0 { + query += ` AND seq limit + if hasMore { + items = items[:limit] + } + sort.Slice(items, func(i, j int) bool { return items[i].Seq < items[j].Seq }) + if len(items) > 0 { + last := items[len(items)-1].Seq + _, _ = a.db.ExecContext(r.Context(), `UPDATE im_conversation_members SET read_seq=GREATEST(read_seq,?) WHERE conversation_id=? AND user_id=?`, last, id, current(r).ID) + } + nextBeforeSeq := int64(0) + if len(items) > 0 { + nextBeforeSeq = items[0].Seq + } + reply(w, map[string]any{"items": items, "hasMore": hasMore, "nextBeforeSeq": nextBeforeSeq}) +} + +func (a *App) sendMessageHTTP(w http.ResponseWriter, r *http.Request) { + conversationID, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "invalid conversation") + return + } + var req struct { + ClientMsgID string `json:"clientMsgId"` + Type int `json:"type"` + Content any `json:"content"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "消息格式错误") + return + } + item, members, err := a.persistMessage(r, conversationID, current(r).ID, req.ClientMsgID, req.Type, req.Content) + if err != nil { + var limitErr *dailyActiveChatLimitError + if errors.As(err, &limitErr) { + fail(w, http.StatusTooManyRequests, 30005, limitErr.Error()) + return + } + fail(w, 400, 30004, err.Error()) + return + } + a.hub.broadcast(members, map[string]any{"command": "MESSAGE_PUSH", "data": item}) + reply(w, item) +} + +func (a *App) recallMessage(w http.ResponseWriter, r *http.Request) { + messageID, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "消息编号无效") + return + } + window, _ := strconv.Atoi(a.configPlain(r.Context(), "im.recall_seconds", "120")) + if window < 1 || window > 86400 { + window = 120 + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "撤回失败") + return + } + defer func() { _ = tx.Rollback() }() + var conversationID, seq int64 + var senderID int64 + var created time.Time + var recalled sql.NullTime + if err = tx.QueryRowContext(r.Context(), `SELECT conversation_id,seq,sender_id,created_at,recalled_at FROM im_messages WHERE id=? FOR UPDATE`, messageID).Scan(&conversationID, &seq, &senderID, &created, &recalled); err != nil { + fail(w, 404, 30001, "消息不存在") + return + } + if senderID != current(r).ID { + fail(w, 403, 30002, "只能撤回自己发送的消息") + return + } + if recalled.Valid { + reply(w, map[string]bool{"success": true}) + return + } + if time.Since(created) > time.Duration(window)*time.Second { + fail(w, 409, 30004, "已超过消息撤回时限") + return + } + _, err = tx.ExecContext(r.Context(), `UPDATE im_messages SET recalled_at=NOW(3) WHERE id=?`, messageID) + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "撤回失败") + return + } + rows, _ := a.db.QueryContext(r.Context(), `SELECT user_id FROM im_conversation_members WHERE conversation_id=? AND status=1`, conversationID) + members := []int64{} + if rows != nil { + defer rows.Close() + for rows.Next() { + var id int64 + _ = rows.Scan(&id) + members = append(members, id) + } + } + a.hub.broadcast(members, map[string]any{"command": "MESSAGE_RECALLED", "data": map[string]any{"id": messageID, "conversationId": conversationID, "seq": seq}}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) persistMessage(r *http.Request, conversationID, senderID int64, clientMsgID string, messageType int, content any) (messageView, []int64, error) { + item := messageView{} + clientMsgID, content, body, err := a.validateMessagePayload(clientMsgID, messageType, content) + if err != nil { + return item, nil, err + } + if messageType == 0 { + messageType = 1 + } + if messageType == 2 || messageType == 3 { + contentMap, _ := content.(map[string]any) + mediaURL, _ := contentMap["url"].(string) + expectedType := "image" + if messageType == 3 { + expectedType = "audio" + } + var owned int + if queryErr := a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM media_assets WHERE owner_user_id=? AND public_url=? AND media_type=? AND status=1 AND moderation_status=1)`, senderID, mediaURL, expectedType).Scan(&owned); queryErr != nil || owned != 1 { + return item, nil, fmt.Errorf("消息媒体必须由当前账号上传") + } + } + if !a.allowRequest(r.Context(), "message_send", fmt.Sprintf("%d", senderID), 120, time.Minute) { + return item, nil, fmt.Errorf("消息发送过于频繁,请稍后再试") + } + if a.isSanctionActive(r.Context(), senderID, "MUTE") { + return item, nil, fmt.Errorf("账号处于禁言期,暂时无法发送消息") + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + return item, nil, err + } + defer func() { _ = tx.Rollback() }() + var lastSeq int64 + if err = tx.QueryRowContext(r.Context(), `SELECT last_seq FROM im_conversations WHERE id=? AND status=1 FOR UPDATE`, conversationID).Scan(&lastSeq); err != nil { + return item, nil, fmt.Errorf("会话不存在") + } + var memberCount int + if err = tx.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM im_conversation_members WHERE conversation_id=? AND user_id=? AND status=1`, conversationID, senderID).Scan(&memberCount); err != nil || memberCount == 0 { + return item, nil, fmt.Errorf("不是会话成员") + } + var blocked int + if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS( + SELECT 1 FROM im_direct_conversations d JOIN user_blocks b + ON (b.user_id=d.user1_id AND b.blocked_user_id=d.user2_id) OR (b.user_id=d.user2_id AND b.blocked_user_id=d.user1_id) + WHERE d.conversation_id=?)`, conversationID).Scan(&blocked); err != nil { + return item, nil, err + } + if blocked == 1 { + return item, nil, fmt.Errorf("当前无法向该用户发送消息") + } + if err = a.reserveDailyActiveChat(r.Context(), tx, conversationID, senderID); err != nil { + return item, nil, err + } + seq := lastSeq + 1 + result, err := tx.ExecContext(r.Context(), `INSERT INTO im_messages(conversation_id,seq,sender_id,client_msg_id,message_type,body)VALUES(?,?,?,?,?,?)`, conversationID, seq, senderID, clientMsgID, messageType, body) + if err != nil { + var existingID, existingSeq int64 + existingErr := tx.QueryRowContext(r.Context(), `SELECT id,seq FROM im_messages WHERE sender_id=? AND client_msg_id=?`, senderID, clientMsgID).Scan(&existingID, &existingSeq) + if existingErr == nil { + _ = tx.Rollback() + return a.loadMessage(r, existingID), nil, nil + } + return item, nil, err + } + messageID, _ := result.LastInsertId() + _, err = tx.ExecContext(r.Context(), `UPDATE im_conversations SET last_seq=?,last_message_id=?,last_message_at=NOW(3) WHERE id=?`, seq, messageID, conversationID) + if err != nil { + return item, nil, err + } + if _, err = tx.ExecContext(r.Context(), `UPDATE im_conversation_members SET delivered_seq=GREATEST(delivered_seq,?),updated_at=NOW(3) WHERE conversation_id=?`, seq, conversationID); err != nil { + return item, nil, err + } + memberRows, err := tx.QueryContext(r.Context(), `SELECT user_id FROM im_conversation_members WHERE conversation_id=? AND status=1`, conversationID) + if err != nil { + return item, nil, err + } + members := []int64{} + for memberRows.Next() { + var userID int64 + if err = memberRows.Scan(&userID); err != nil { + _ = memberRows.Close() + return item, nil, err + } + members = append(members, userID) + } + if err = memberRows.Err(); err != nil { + _ = memberRows.Close() + return item, nil, err + } + if err = memberRows.Close(); err != nil { + return item, nil, err + } + sort.Slice(members, func(i, j int) bool { return members[i] < members[j] }) + for _, userID := range members { + var lockedUserID int64 + if err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE id=? FOR UPDATE`, userID).Scan(&lockedUserID); err != nil { + return item, nil, err + } + } + for _, userID := range members { + var next int64 + if err = tx.QueryRowContext(r.Context(), `SELECT COALESCE(MAX(event_seq),0)+1 FROM im_user_sync_events WHERE user_id=?`, userID).Scan(&next); err != nil { + return item, nil, err + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO im_user_sync_events(user_id,event_seq,event_type,conversation_id,message_seq,event_data)VALUES(?, ?,12,?,?,?)`, userID, next, conversationID, seq, body); err != nil { + return item, nil, err + } + } + if err = tx.Commit(); err != nil { + return item, nil, err + } + return messageView{ID: messageID, ConversationID: conversationID, Seq: seq, SenderID: senderID, ClientMsgID: clientMsgID, Type: messageType, Content: content, CreatedAt: time.Now()}, members, nil +} + +func (a *App) loadMessage(r *http.Request, id int64) messageView { + var item messageView + var body []byte + var recalledAt, adminRemovedAt sql.NullTime + _ = a.db.QueryRowContext(r.Context(), `SELECT id,conversation_id,seq,sender_id,client_msg_id,message_type,body,recalled_at,admin_removed_at,created_at FROM im_messages WHERE id=?`, id).Scan(&item.ID, &item.ConversationID, &item.Seq, &item.SenderID, &item.ClientMsgID, &item.Type, &body, &recalledAt, &adminRemovedAt, &item.CreatedAt) + item.Recalled = recalledAt.Valid || adminRemovedAt.Valid + if !item.Recalled { + _ = json.Unmarshal(body, &item.Content) + } + return item +} +func (a *App) isMember(r *http.Request, conversationID, userID int64) bool { + var count int + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM im_conversation_members WHERE conversation_id=? AND user_id=? AND status=1`, conversationID, userID).Scan(&count) + return count > 0 +} + +func (a *App) conversationSettings(w http.ResponseWriter, r *http.Request) { + id, pathErr := pathID(r) + if pathErr != nil { + fail(w, http.StatusBadRequest, 20001, "会话 ID 无效") + return + } + var req struct { + Pinned *bool `json:"pinned"` + Muted *bool `json:"muted"` + ReadSeq int64 `json:"readSeq"` + } + if decode(r, &req) != nil || req.ReadSeq < 0 { + fail(w, 400, 20001, "invalid settings") + return + } + pinned, muted := -1, -1 + if req.Pinned != nil { + pinned = btoi(*req.Pinned) + } + if req.Muted != nil { + muted = btoi(*req.Muted) + } + var lastSeq int64 + if err := a.db.QueryRowContext(r.Context(), `SELECT c.last_seq FROM im_conversations c JOIN im_conversation_members m ON m.conversation_id=c.id WHERE c.id=? AND m.user_id=? AND m.status=1`, id, current(r).ID).Scan(&lastSeq); err != nil { + fail(w, http.StatusForbidden, 30002, "不是会话成员") + return + } + readSeq := req.ReadSeq + if readSeq > lastSeq { + readSeq = lastSeq + } + _, err := a.db.ExecContext(r.Context(), `UPDATE im_conversation_members SET pinned=IF(?>=0,?,pinned),muted=IF(?>=0,?,muted),read_seq=GREATEST(read_seq,?) WHERE conversation_id=? AND user_id=?`, pinned, pinned, muted, muted, readSeq, id, current(r).ID) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) websocket(w http.ResponseWriter, r *http.Request) { + if !a.originAllowed(r.Header.Get("Origin")) { + fail(w, http.StatusForbidden, 10006, "WebSocket 来源不允许") + return + } + selectedProtocol := "" + raw := "" + for _, protocol := range websocket.Subprotocols(r) { + if strings.HasPrefix(protocol, "xingyu.jwt.") { + selectedProtocol = protocol + raw = strings.TrimPrefix(protocol, "xingyu.jwt.") + break + } + } + // Query-token compatibility is development-only because URLs may be written to proxy logs. + if raw == "" && a.config.Environment != "production" { + raw = r.URL.Query().Get("token") + } + who, err := a.parseToken(raw) + if err != nil || who.Role != "user" { + fail(w, 401, 10001, "invalid token") + return + } + var userStatus int + statusErr := a.db.QueryRowContext(r.Context(), `SELECT status FROM users WHERE id=? AND deleted_at IS NULL`, who.ID).Scan(&userStatus) + if statusErr == nil { + userStatus = a.normalizeUserStatus(r.Context(), who.ID, userStatus) + } + if statusErr != nil || userStatus != 1 { + fail(w, 403, 10006, "账号已被冻结或封禁") + return + } + var tokenVersion int + _ = a.db.QueryRowContext(r.Context(), `SELECT token_version FROM user_security_controls WHERE user_id=?`, who.ID).Scan(&tokenVersion) + if who.Version != tokenVersion { + fail(w, 401, 10001, "登录状态已失效") + return + } + upgrader := websocket.Upgrader{CheckOrigin: func(request *http.Request) bool { + return a.originAllowed(request.Header.Get("Origin")) + }} + if selectedProtocol != "" { + upgrader.Subprotocols = []string{selectedProtocol} + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + client := &wsClient{userID: who.ID, conn: conn} + conn.SetReadLimit(64 << 10) + _ = conn.SetReadDeadline(time.Now().Add(75 * time.Second)) + a.hub.add(client) + defer func() { a.hub.remove(client); _ = conn.Close() }() + _ = client.writeJSON(map[string]any{"command": "AUTH_ACK", "data": map[string]any{"heartbeatSeconds": 25, "serverTime": time.Now().UnixMilli()}}) + for { + var frame struct { + Command string `json:"command"` + ConversationID int64 `json:"conversationId"` + ClientMsgID string `json:"clientMsgId"` + Type int `json:"type"` + Content any `json:"content"` + ReadSeq int64 `json:"readSeq"` + } + if conn.ReadJSON(&frame) != nil { + return + } + switch frame.Command { + case "PING": + _ = conn.SetReadDeadline(time.Now().Add(75 * time.Second)) + _ = client.writeJSON(map[string]any{"command": "PONG", "timestamp": time.Now().UnixMilli()}) + case "SEND_MESSAGE": + item, members, persistErr := a.persistMessage(r, frame.ConversationID, who.ID, frame.ClientMsgID, frame.Type, frame.Content) + if persistErr != nil { + _ = client.writeJSON(map[string]any{"command": "ERROR", "message": persistErr.Error()}) + continue + } + a.hub.broadcast(members, map[string]any{"command": "MESSAGE_PUSH", "data": item}) + case "READ": + if frame.ReadSeq < 0 { + _ = client.writeJSON(map[string]any{"command": "ERROR", "message": "已读序号无效"}) + continue + } + var lastSeq int64 + if a.db.QueryRowContext(r.Context(), `SELECT c.last_seq FROM im_conversations c JOIN im_conversation_members m ON m.conversation_id=c.id WHERE c.id=? AND m.user_id=? AND m.status=1`, frame.ConversationID, who.ID).Scan(&lastSeq) != nil { + _ = client.writeJSON(map[string]any{"command": "ERROR", "message": "不是会话成员"}) + continue + } + if frame.ReadSeq > lastSeq { + frame.ReadSeq = lastSeq + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE im_conversation_members SET read_seq=GREATEST(read_seq,?) WHERE conversation_id=? AND user_id=?`, frame.ReadSeq, frame.ConversationID, who.ID) + a.hub.broadcast([]int64{who.ID}, map[string]any{"command": "READ_ACK", "data": frame}) + } + } +} + +func max64(a, b int64) int64 { + if a > b { + return a + } + return b +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/im_validation_test.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/im_validation_test.go new file mode 100644 index 0000000..0561ad3 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/im_validation_test.go @@ -0,0 +1,39 @@ +package app + +import ( + "strings" + "testing" +) + +func TestValidateMessagePayload(t *testing.T) { + application := &App{config: Config{Environment: "development"}} + clientID, content, body, err := application.validateMessagePayload("client-1", 1, map[string]any{"text": " 你好 "}) + if err != nil { + t.Fatalf("expected valid text message: %v", err) + } + if clientID != "client-1" || content.(map[string]any)["text"] != "你好" || len(body) == 0 { + t.Fatalf("message was not normalized: %#v %#v", clientID, content) + } + if _, _, _, err = application.validateMessagePayload("bad id", 1, map[string]any{"text": "hello"}); err == nil { + t.Fatal("client message IDs containing spaces must be rejected") + } + if _, _, _, err = application.validateMessagePayload("client-2", 1, map[string]any{"text": strings.Repeat("好", maxMessageTextRunes+1)}); err == nil { + t.Fatal("oversized text messages must be rejected") + } + if _, _, _, err = application.validateMessagePayload("client-3", 3, map[string]any{"duration": 61.0, "url": "/uploads/voice.mp3"}); err == nil { + t.Fatal("voice messages longer than 60 seconds must be rejected") + } +} + +func TestProductionMessageMediaRequiresHTTPS(t *testing.T) { + application := &App{config: Config{Environment: "production"}} + if _, _, _, err := application.validateMessagePayload("client-1", 2, map[string]any{"url": "http://cdn.example.com/a.jpg"}); err == nil { + t.Fatal("production media URLs must use HTTPS") + } + if _, _, _, err := application.validateMessagePayload("client-2", 2, map[string]any{"url": "https://cdn.example.com/a.jpg"}); err != nil { + t.Fatalf("valid HTTPS media URL rejected: %v", err) + } + if validMessageMediaURL("/uploads/../secret", true) { + t.Fatal("local media paths must not allow traversal") + } +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/integration.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/integration.go new file mode 100644 index 0000000..e27a59e --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/integration.go @@ -0,0 +1,577 @@ +package app + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +const maskedSecret = "••••••••" + +type integrationFieldSpec struct { + Key string + Label string + Input string + Required bool + Options []string + Providers []string + Description string +} + +type integrationFieldView struct { + Key string `json:"key"` + Label string `json:"label"` + Value string `json:"value"` + Input string `json:"input"` + Secret bool `json:"secret"` + HasValue bool `json:"hasValue"` + Required bool `json:"required"` + Options []string `json:"options,omitempty"` + Providers []string `json:"providers,omitempty"` + Description string `json:"description"` +} + +var integrationSpecs = map[string][]integrationFieldSpec{ + "oauth": { + {Key: "oauth.admin.frontend_callback_url", Label: "管理端登录结果页", Input: "text", Required: true, Description: "第三方授权完成后返回的管理端页面;生产环境必须使用 HTTPS"}, + {Key: "oauth.user.frontend_callback_url", Label: "客户端 H5 登录结果页", Input: "text", Required: true, Description: "第三方授权完成后返回的 uni-app H5 页面;生产环境必须使用 HTTPS"}, + + {Key: "oauth.wechat.enabled", Label: "管理端启用微信登录", Input: "boolean", Required: true, Providers: []string{"wechat"}, Description: "开启后且必填参数完整时,管理端登录页显示微信入口"}, + {Key: "oauth.user.wechat.enabled", Label: "客户端启用微信登录", Input: "boolean", Required: true, Providers: []string{"wechat"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示微信入口"}, + {Key: "oauth.wechat.client_id", Label: "微信 AppID", Input: "text", Providers: []string{"wechat"}, Description: "微信开放平台网站应用 AppID"}, + {Key: "oauth.wechat.client_secret", Label: "微信 AppSecret", Input: "secret", Providers: []string{"wechat"}, Description: "AES-GCM 加密保存"}, + {Key: "oauth.wechat.authorization_url", Label: "微信授权地址", Input: "text", Providers: []string{"wechat"}, Description: "默认使用微信开放平台 qrconnect 地址"}, + {Key: "oauth.wechat.token_url", Label: "微信令牌地址", Input: "text", Providers: []string{"wechat"}, Description: "授权码换取 access_token 的地址"}, + {Key: "oauth.wechat.userinfo_url", Label: "微信用户信息地址", Input: "text", Providers: []string{"wechat"}, Description: "获取登录用户 OpenID 与资料的地址"}, + {Key: "oauth.wechat.scope", Label: "微信授权范围", Input: "text", Providers: []string{"wechat"}, Description: "网站扫码登录通常为 snsapi_login"}, + {Key: "oauth.wechat.redirect_uri", Label: "微信回调地址", Input: "text", Providers: []string{"wechat"}, Description: "必须与微信开放平台登记值完全一致"}, + + {Key: "oauth.qq.enabled", Label: "管理端启用 QQ 登录", Input: "boolean", Required: true, Providers: []string{"qq"}, Description: "开启后且必填参数完整时,管理端登录页显示 QQ 入口"}, + {Key: "oauth.user.qq.enabled", Label: "客户端启用 QQ 登录", Input: "boolean", Required: true, Providers: []string{"qq"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示 QQ 入口"}, + {Key: "oauth.qq.client_id", Label: "QQ AppID", Input: "text", Providers: []string{"qq"}, Description: "QQ 互联应用 AppID"}, + {Key: "oauth.qq.client_secret", Label: "QQ AppKey", Input: "secret", Providers: []string{"qq"}, Description: "AES-GCM 加密保存"}, + {Key: "oauth.qq.authorization_url", Label: "QQ 授权地址", Input: "text", Providers: []string{"qq"}, Description: "QQ OAuth 2.0 authorize 地址"}, + {Key: "oauth.qq.token_url", Label: "QQ 令牌地址", Input: "text", Providers: []string{"qq"}, Description: "授权码换取 access_token 的地址"}, + {Key: "oauth.qq.openid_url", Label: "QQ OpenID 地址", Input: "text", Providers: []string{"qq"}, Description: "使用 access_token 获取 QQ OpenID"}, + {Key: "oauth.qq.userinfo_url", Label: "QQ 用户信息地址", Input: "text", Providers: []string{"qq"}, Description: "获取昵称和头像"}, + {Key: "oauth.qq.scope", Label: "QQ 授权范围", Input: "text", Providers: []string{"qq"}, Description: "默认 get_user_info"}, + {Key: "oauth.qq.redirect_uri", Label: "QQ 回调地址", Input: "text", Providers: []string{"qq"}, Description: "必须与 QQ 互联登记值完全一致"}, + + {Key: "oauth.github.enabled", Label: "管理端启用 GitHub 登录", Input: "boolean", Required: true, Providers: []string{"github"}, Description: "开启后且必填参数完整时,管理端登录页显示 GitHub 入口"}, + {Key: "oauth.user.github.enabled", Label: "客户端启用 GitHub 登录", Input: "boolean", Required: true, Providers: []string{"github"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示 GitHub 入口"}, + {Key: "oauth.github.client_id", Label: "GitHub Client ID", Input: "text", Providers: []string{"github"}, Description: "GitHub OAuth App Client ID"}, + {Key: "oauth.github.client_secret", Label: "GitHub Client Secret", Input: "secret", Providers: []string{"github"}, Description: "AES-GCM 加密保存"}, + {Key: "oauth.github.authorization_url", Label: "GitHub 授权地址", Input: "text", Providers: []string{"github"}, Description: "GitHub OAuth authorize 地址"}, + {Key: "oauth.github.token_url", Label: "GitHub 令牌地址", Input: "text", Providers: []string{"github"}, Description: "授权码换取 access_token 的地址"}, + {Key: "oauth.github.userinfo_url", Label: "GitHub 用户信息地址", Input: "text", Providers: []string{"github"}, Description: "默认使用 /user 接口"}, + {Key: "oauth.github.scope", Label: "GitHub 授权范围", Input: "text", Providers: []string{"github"}, Description: "建议仅 read:user user:email"}, + {Key: "oauth.github.redirect_uri", Label: "GitHub 回调地址", Input: "text", Providers: []string{"github"}, Description: "必须与 OAuth App 的 callback URL 完全一致"}, + + {Key: "oauth.google.enabled", Label: "管理端启用 Google 登录", Input: "boolean", Required: true, Providers: []string{"google"}, Description: "开启后且必填参数完整时,管理端登录页显示 Google 入口"}, + {Key: "oauth.user.google.enabled", Label: "客户端启用 Google 登录", Input: "boolean", Required: true, Providers: []string{"google"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示 Google 入口"}, + {Key: "oauth.google.client_id", Label: "Google Client ID", Input: "text", Providers: []string{"google"}, Description: "Google OAuth 2.0 Client ID"}, + {Key: "oauth.google.client_secret", Label: "Google Client Secret", Input: "secret", Providers: []string{"google"}, Description: "AES-GCM 加密保存"}, + {Key: "oauth.google.authorization_url", Label: "Google 授权地址", Input: "text", Providers: []string{"google"}, Description: "Google OAuth authorization endpoint"}, + {Key: "oauth.google.token_url", Label: "Google 令牌地址", Input: "text", Providers: []string{"google"}, Description: "Google OAuth token endpoint"}, + {Key: "oauth.google.userinfo_url", Label: "Google UserInfo 地址", Input: "text", Providers: []string{"google"}, Description: "OpenID Connect UserInfo endpoint"}, + {Key: "oauth.google.scope", Label: "Google 授权范围", Input: "text", Providers: []string{"google"}, Description: "至少包含 openid profile email"}, + {Key: "oauth.google.redirect_uri", Label: "Google 回调地址", Input: "text", Providers: []string{"google"}, Description: "必须与 Google Cloud Console 登记值完全一致"}, + }, + "storage": { + {Key: "storage.provider", Label: "当前存储厂商", Input: "select", Required: true, Options: []string{"local", "aliyun_oss", "tencent_cos", "qiniu", "huawei_obs", "huawei_flexus"}, Description: "保存后所有新上传文件立即切换到所选存储;历史文件地址不受影响"}, + {Key: "storage.object_prefix", Label: "云端对象前缀", Input: "text", Required: true, Description: "仅允许字母、数字、斜杠、下划线和短横线,例如 media"}, + + {Key: "storage.local.directory", Label: "本地存储目录", Input: "text", Required: true, Providers: []string{"local"}, Description: "相对路径基于后端运行目录;禁止配置为磁盘根目录"}, + {Key: "storage.local.public_base_url", Label: "本地公开访问地址", Input: "text", Providers: []string{"local"}, Description: "可选,例如 https://api.example.com/uploads;留空时根据当前请求生成"}, + + {Key: "storage.aliyun_oss.endpoint", Label: "阿里云 OSS Endpoint", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "例如 https://oss-cn-hangzhou.aliyuncs.com"}, + {Key: "storage.aliyun_oss.region", Label: "阿里云 OSS Region", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "例如 cn-hangzhou"}, + {Key: "storage.aliyun_oss.bucket", Label: "阿里云 OSS Bucket", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "需要 oss:PutObject 权限"}, + {Key: "storage.aliyun_oss.access_key_id", Label: "阿里云 AccessKey ID", Input: "secret", Required: true, Providers: []string{"aliyun_oss"}, Description: "建议使用最小权限 RAM 用户,AES-GCM 加密保存"}, + {Key: "storage.aliyun_oss.access_key_secret", Label: "阿里云 AccessKey Secret", Input: "secret", Required: true, Providers: []string{"aliyun_oss"}, Description: "AES-GCM 加密保存"}, + {Key: "storage.aliyun_oss.public_base_url", Label: "阿里云文件访问域名", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "Bucket 公网域名或已配置的 CDN/自定义域名,必须使用 HTTPS"}, + + {Key: "storage.tencent_cos.endpoint", Label: "腾讯云 COS Bucket URL", Input: "text", Required: true, Providers: []string{"tencent_cos"}, Description: "例如 https://bucket-appid.cos.ap-guangzhou.myqcloud.com"}, + {Key: "storage.tencent_cos.bucket", Label: "腾讯云 COS Bucket", Input: "text", Required: true, Providers: []string{"tencent_cos"}, Description: "完整名称需包含 APPID,需要 cos:PutObject 权限"}, + {Key: "storage.tencent_cos.secret_id", Label: "腾讯云 SecretId", Input: "secret", Required: true, Providers: []string{"tencent_cos"}, Description: "建议使用最小权限 CAM 子账号密钥,AES-GCM 加密保存"}, + {Key: "storage.tencent_cos.secret_key", Label: "腾讯云 SecretKey", Input: "secret", Required: true, Providers: []string{"tencent_cos"}, Description: "AES-GCM 加密保存"}, + {Key: "storage.tencent_cos.public_base_url", Label: "腾讯云文件访问域名", Input: "text", Required: true, Providers: []string{"tencent_cos"}, Description: "Bucket 公网域名或 CDN 域名,必须使用 HTTPS"}, + + {Key: "storage.qiniu.bucket", Label: "七牛云空间名称", Input: "text", Required: true, Providers: []string{"qiniu"}, Description: "Kodo Bucket 名称,SDK 自动发现上传区域"}, + {Key: "storage.qiniu.access_key", Label: "七牛云 AccessKey", Input: "secret", Required: true, Providers: []string{"qiniu"}, Description: "建议使用仅具备目标空间上传权限的密钥,AES-GCM 加密保存"}, + {Key: "storage.qiniu.secret_key", Label: "七牛云 SecretKey", Input: "secret", Required: true, Providers: []string{"qiniu"}, Description: "AES-GCM 加密保存"}, + {Key: "storage.qiniu.public_base_url", Label: "七牛云文件访问域名", Input: "text", Required: true, Providers: []string{"qiniu"}, Description: "空间绑定域名或 CDN 域名,必须使用 HTTPS"}, + + {Key: "storage.huawei_obs.endpoint", Label: "华为云 OBS Endpoint", Input: "text", Required: true, Providers: []string{"huawei_obs"}, Description: "例如 https://obs.cn-north-4.myhuaweicloud.com"}, + {Key: "storage.huawei_obs.bucket", Label: "华为云 OBS Bucket", Input: "text", Required: true, Providers: []string{"huawei_obs"}, Description: "需要 obs:object:PutObject 权限"}, + {Key: "storage.huawei_obs.access_key", Label: "华为云 OBS Access Key", Input: "secret", Required: true, Providers: []string{"huawei_obs"}, Description: "IAM 用户 AK,AES-GCM 加密保存"}, + {Key: "storage.huawei_obs.secret_key", Label: "华为云 OBS Secret Key", Input: "secret", Required: true, Providers: []string{"huawei_obs"}, Description: "IAM 用户 SK,AES-GCM 加密保存"}, + {Key: "storage.huawei_obs.public_base_url", Label: "华为云 OBS 文件访问域名", Input: "text", Required: true, Providers: []string{"huawei_obs"}, Description: "Bucket 公网域名或 CDN 域名,必须使用 HTTPS"}, + + {Key: "storage.huawei_flexus.endpoint", Label: "Flexus 对象存储 Endpoint", Input: "text", Required: true, Providers: []string{"huawei_flexus"}, Description: "Flexus 对象存储控制台提供的 OBS 兼容 Endpoint"}, + {Key: "storage.huawei_flexus.bucket", Label: "Flexus Bucket", Input: "text", Required: true, Providers: []string{"huawei_flexus"}, Description: "Flexus 对象存储桶名称"}, + {Key: "storage.huawei_flexus.access_key", Label: "Flexus Access Key", Input: "secret", Required: true, Providers: []string{"huawei_flexus"}, Description: "AES-GCM 加密保存"}, + {Key: "storage.huawei_flexus.secret_key", Label: "Flexus Secret Key", Input: "secret", Required: true, Providers: []string{"huawei_flexus"}, Description: "AES-GCM 加密保存"}, + {Key: "storage.huawei_flexus.public_base_url", Label: "Flexus 文件访问域名", Input: "text", Required: true, Providers: []string{"huawei_flexus"}, Description: "Bucket 公网域名或 CDN 域名,必须使用 HTTPS"}, + }, + "sms": { + {Key: "sms.enabled", Label: "启用短信服务", Input: "boolean", Required: true, Description: "关闭后将拒绝发送验证码"}, + {Key: "sms.provider", Label: "当前短信厂商", Input: "select", Required: true, Options: []string{"aliyun", "tencent", "huawei", "webhook", "debug"}, Description: "保存后新验证码立即切换到所选厂商;debug 仅限本地开发"}, + {Key: "sms.expire_seconds", Label: "有效期(秒)", Input: "number", Required: true, Description: "建议 120 至 600 秒"}, + {Key: "sms.debug_code", Label: "调试验证码", Input: "secret", Required: true, Providers: []string{"debug"}, Description: "仅 debug 模式返回给客户端"}, + + {Key: "sms.aliyun.endpoint", Label: "阿里云 API 地址", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "国内短信默认 https://dysmsapi.aliyuncs.com"}, + {Key: "sms.aliyun.access_key_id", Label: "阿里云 AccessKey ID", Input: "secret", Required: true, Providers: []string{"aliyun"}, Description: "建议使用仅授予短信发送权限的 RAM 用户"}, + {Key: "sms.aliyun.access_key_secret", Label: "阿里云 AccessKey Secret", Input: "secret", Required: true, Providers: []string{"aliyun"}, Description: "AES-GCM 加密保存"}, + {Key: "sms.aliyun.sign_name", Label: "阿里云短信签名", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "必须是审核通过的签名名称"}, + {Key: "sms.aliyun.template_register", Label: "阿里云注册模板 Code", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "注册验证码模板,例如 SMS_123456789"}, + {Key: "sms.aliyun.template_login", Label: "阿里云登录模板 Code", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "短信登录验证码模板"}, + {Key: "sms.aliyun.template_reset", Label: "阿里云重置密码模板 Code", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "找回密码验证码模板"}, + {Key: "sms.aliyun.template_params", Label: "阿里云模板变量 JSON", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "支持 {{code}} 与 {{minutes}},例如 {\"code\":\"{{code}}\"}"}, + + {Key: "sms.tencent.endpoint", Label: "腾讯云 API 地址", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "国内短信默认 https://sms.tencentcloudapi.com"}, + {Key: "sms.tencent.secret_id", Label: "腾讯云 SecretId", Input: "secret", Required: true, Providers: []string{"tencent"}, Description: "建议使用最小权限 CAM 子账号密钥"}, + {Key: "sms.tencent.secret_key", Label: "腾讯云 SecretKey", Input: "secret", Required: true, Providers: []string{"tencent"}, Description: "AES-GCM 加密保存"}, + {Key: "sms.tencent.sdk_app_id", Label: "腾讯云短信 SdkAppId", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "短信控制台应用 ID"}, + {Key: "sms.tencent.region", Label: "腾讯云地域", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "国内短信建议 ap-guangzhou"}, + {Key: "sms.tencent.sign_name", Label: "腾讯云短信签名", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "必须是审核通过的签名内容"}, + {Key: "sms.tencent.template_register", Label: "腾讯云注册模板 ID", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "注册验证码模板 ID"}, + {Key: "sms.tencent.template_login", Label: "腾讯云登录模板 ID", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "短信登录验证码模板 ID"}, + {Key: "sms.tencent.template_reset", Label: "腾讯云重置密码模板 ID", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "找回密码验证码模板 ID"}, + {Key: "sms.tencent.template_params", Label: "腾讯云模板参数 JSON", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "参数按模板变量顺序排列,例如 [\"{{code}}\"]"}, + + {Key: "sms.huawei.endpoint", Label: "华为云 APP 接入地址", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "填写控制台提供的 HTTPS 地址,包含 /sms/batchSendSms/v1"}, + {Key: "sms.huawei.app_key", Label: "华为云 Application Key", Input: "secret", Required: true, Providers: []string{"huawei"}, Description: "短信应用的 APP_Key"}, + {Key: "sms.huawei.app_secret", Label: "华为云 Application Secret", Input: "secret", Required: true, Providers: []string{"huawei"}, Description: "短信应用的 APP_Secret,AES-GCM 加密保存"}, + {Key: "sms.huawei.sender", Label: "华为云签名通道号", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "国内短信签名审核后分配的通道号"}, + {Key: "sms.huawei.signature", Label: "华为云签名名称", Input: "text", Providers: []string{"huawei"}, Description: "通用模板需要填写已审核签名;非通用模板可留空"}, + {Key: "sms.huawei.template_register", Label: "华为云注册模板 ID", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "注册验证码模板 ID"}, + {Key: "sms.huawei.template_login", Label: "华为云登录模板 ID", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "短信登录验证码模板 ID"}, + {Key: "sms.huawei.template_reset", Label: "华为云重置密码模板 ID", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "找回密码验证码模板 ID"}, + {Key: "sms.huawei.template_params", Label: "华为云模板参数 JSON", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "参数按模板变量顺序排列,例如 [\"{{code}}\"]"}, + {Key: "sms.huawei.status_callback", Label: "华为云状态回调地址", Input: "text", Providers: []string{"huawei"}, Description: "可选,接收运营商最终送达状态"}, + + {Key: "sms.sign_name", Label: "Webhook 短信签名", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "发送给自建网关的签名名称"}, + {Key: "sms.template_register", Label: "Webhook 注册模板 ID", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "注册场景模板"}, + {Key: "sms.template_login", Label: "Webhook 登录模板 ID", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "验证码登录场景模板"}, + {Key: "sms.template_reset", Label: "Webhook 找回密码模板 ID", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "重置密码场景模板"}, + {Key: "sms.webhook_url", Label: "Webhook 地址", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "接收 JSON POST;生产环境必须使用 HTTPS"}, + {Key: "sms.webhook_token", Label: "Webhook Token", Input: "secret", Providers: []string{"webhook"}, Description: "以 Bearer Token 发送,仅显示保存状态"}, + }, + "payment": { + {Key: "payment.mode", Label: "支付模式", Input: "select", Required: true, Options: []string{"sandbox", "live"}, Description: "sandbox 可直接完成本地支付闭环"}, + {Key: "payment.gateway.create_url", Label: "支付网关下单地址", Input: "text", Description: "live 模式必填,生产环境必须使用 HTTPS"}, + {Key: "payment.gateway.refund_url", Label: "支付网关退款地址", Input: "text", Description: "live 模式退款必填,生产环境必须使用 HTTPS"}, + {Key: "payment.gateway.token", Label: "支付网关访问令牌", Input: "secret", Description: "live 模式必填,以 Bearer Token 调用统一支付网关"}, + {Key: "payment.gateway.notify_secret", Label: "支付回调签名密钥", Input: "secret", Description: "live 模式必填,至少 32 位随机字符串,用于 HMAC-SHA256 验签"}, + {Key: "payment.gateway.notify_url", Label: "支付异步回调地址", Input: "text", Description: "live 模式必填,例如 https://api.example.com/api/v1/payment/notify"}, + {Key: "payment.gateway.return_url", Label: "支付完成返回地址", Input: "text", Description: "H5 支付完成后返回的客户端地址"}, + {Key: "payment.gateway.timeout_seconds", Label: "网关超时(秒)", Input: "number", Description: "建议 5 至 30 秒"}, + {Key: "payment.alipay.enabled", Label: "启用支付宝", Input: "boolean", Required: true, Description: "控制客户端支付宝入口"}, + {Key: "payment.alipay.app_id", Label: "支付宝 APPID", Input: "text", Description: "开放平台应用 APPID"}, + {Key: "payment.alipay.private_key", Label: "支付宝应用私钥", Input: "secret", Description: "敏感字段加密存储"}, + {Key: "payment.alipay.public_key", Label: "支付宝公钥", Input: "secret", Description: "用于验签"}, + {Key: "payment.alipay.notify_url", Label: "支付宝通知地址", Input: "text", Description: "必须是公网 HTTPS 地址"}, + {Key: "payment.wechat.enabled", Label: "启用微信支付", Input: "boolean", Required: true, Description: "控制客户端微信支付入口"}, + {Key: "payment.wechat.app_id", Label: "微信 AppID", Input: "text", Description: "移动应用或小程序 AppID"}, + {Key: "payment.wechat.mch_id", Label: "微信商户号", Input: "text", Description: "微信支付商户号"}, + {Key: "payment.wechat.api_v3_key", Label: "APIv3 密钥", Input: "secret", Description: "敏感字段加密存储"}, + {Key: "payment.wechat.private_key", Label: "商户私钥", Input: "secret", Description: "PEM 内容,敏感字段加密存储"}, + {Key: "payment.wechat.serial_no", Label: "证书序列号", Input: "text", Description: "商户 API 证书序列号"}, + {Key: "payment.wechat.notify_url", Label: "微信通知地址", Input: "text", Description: "必须是公网 HTTPS 地址"}, + }, +} + +func (a *App) configPlain(ctx context.Context, key, fallback string) string { + var value, valueType string + if err := a.db.QueryRowContext(ctx, `SELECT config_value,value_type FROM system_configs WHERE config_key=?`, key).Scan(&value, &valueType); err != nil { + return fallback + } + if valueType == "secret" && value != "" { + plain, err := a.decryptSecret(value) + if err != nil { + return fallback + } + return plain + } + return value +} + +func (a *App) configBool(ctx context.Context, key string, fallback bool) bool { + value := strings.ToLower(a.configPlain(ctx, key, strconv.FormatBool(fallback))) + return value == "1" || value == "true" || value == "yes" || value == "on" +} + +func (a *App) encryptSecret(plain string) (string, error) { + if plain == "" { + return "", nil + } + key := sha256.Sum256([]byte(a.integrationEncryptionKey())) + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + sealed := gcm.Seal(nonce, nonce, []byte(plain), nil) + return "enc:v1:" + base64.RawStdEncoding.EncodeToString(sealed), nil +} + +func (a *App) decryptSecret(value string) (string, error) { + if value == "" { + return "", nil + } + if !strings.HasPrefix(value, "enc:v1:") { + return value, nil + } + encoded := strings.TrimPrefix(value, "enc:v1:") + payload, err := base64.RawStdEncoding.DecodeString(encoded) + if err != nil { + return "", err + } + key := sha256.Sum256([]byte(a.integrationEncryptionKey())) + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil || len(payload) < gcm.NonceSize() { + return "", fmt.Errorf("invalid encrypted secret") + } + returnValue, err := gcm.Open(nil, payload[:gcm.NonceSize()], payload[gcm.NonceSize():], nil) + if err == nil { + return string(returnValue), nil + } + if a.config.ConfigEncryptionKey != "" { + legacyKey := sha256.Sum256([]byte(a.config.JWTSecret + ":integration-config")) + legacyBlock, legacyErr := aes.NewCipher(legacyKey[:]) + if legacyErr == nil { + legacyGCM, legacyErr := cipher.NewGCM(legacyBlock) + if legacyErr == nil && len(payload) >= legacyGCM.NonceSize() { + returnValue, legacyErr = legacyGCM.Open(nil, payload[:legacyGCM.NonceSize()], payload[legacyGCM.NonceSize():], nil) + if legacyErr == nil { + return string(returnValue), nil + } + } + } + } + return "", err +} + +func (a *App) integrationEncryptionKey() string { + if a.config.ConfigEncryptionKey != "" { + return a.config.ConfigEncryptionKey + } + // Development-only compatibility for databases created before the dedicated key existed. + return a.config.JWTSecret + ":integration-config" +} + +func integrationGroup(r *http.Request) string { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + for index, part := range parts { + if part == "integrations" && index+1 < len(parts) { + return parts[index+1] + } + } + return "" +} + +func (a *App) adminIntegration(w http.ResponseWriter, r *http.Request) { + group := integrationGroup(r) + specs, ok := integrationSpecs[group] + if !ok { + fail(w, http.StatusNotFound, 30001, "配置分组不存在") + return + } + fields := make([]integrationFieldView, 0, len(specs)) + configured := true + activeProvider := "" + if group == "sms" { + activeProvider = a.configPlain(r.Context(), "sms.provider", "debug") + configured = a.configBool(r.Context(), "sms.enabled", false) + } else if group == "storage" { + activeProvider = a.configPlain(r.Context(), "storage.provider", "local") + } else if group == "oauth" { + configured = a.oauthConfigurationReady(r.Context()) + } + for _, spec := range specs { + var value, valueType string + _ = a.db.QueryRowContext(r.Context(), `SELECT config_value,value_type FROM system_configs WHERE config_key=?`, spec.Key).Scan(&value, &valueType) + secret := valueType == "secret" || spec.Input == "secret" + hasValue := value != "" + displayValue := value + if secret { + displayValue = "" + } + applies := len(spec.Providers) == 0 || containsString(spec.Providers, activeProvider) + if group == "oauth" { + applies = false // OAuth 必填项由各渠道的启用状态独立校验。 + } + if applies && spec.Required && !hasValue { + configured = false + } + fields = append(fields, integrationFieldView{Key: spec.Key, Label: spec.Label, Value: displayValue, Input: spec.Input, Secret: secret, HasValue: hasValue, Required: spec.Required, Options: spec.Options, Providers: spec.Providers, Description: spec.Description}) + } + reply(w, map[string]any{"group": group, "configured": configured, "fields": fields, "secretMask": maskedSecret}) +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func (a *App) adminUpdateIntegration(w http.ResponseWriter, r *http.Request) { + group := integrationGroup(r) + specs, ok := integrationSpecs[group] + if !ok { + fail(w, http.StatusNotFound, 30001, "配置分组不存在") + return + } + var req struct { + Values map[string]string `json:"values"` + ClearSecrets []string `json:"clearSecrets"` + } + if decode(r, &req) != nil || req.Values == nil { + fail(w, http.StatusBadRequest, 20001, "配置格式错误") + return + } + allowed := map[string]integrationFieldSpec{} + for _, spec := range specs { + allowed[spec.Key] = spec + } + for key, value := range req.Values { + spec, exists := allowed[key] + if !exists { + fail(w, http.StatusBadRequest, 20001, "配置项不允许修改") + return + } + switch spec.Input { + case "select": + if !containsString(spec.Options, value) { + fail(w, http.StatusBadRequest, 20001, spec.Label+"选项无效") + return + } + case "boolean": + if value != "true" && value != "false" { + fail(w, http.StatusBadRequest, 20001, spec.Label+"必须为 true 或 false") + return + } + case "number": + number, parseErr := strconv.Atoi(value) + if parseErr != nil { + fail(w, http.StatusBadRequest, 20001, spec.Label+"必须是数字") + return + } + if key == "sms.expire_seconds" && (number < 60 || number > 1800) { + fail(w, http.StatusBadRequest, 20001, "短信有效期必须在 60 到 1800 秒之间") + return + } + if key == "payment.gateway.timeout_seconds" && (number < 3 || number > 30) { + fail(w, http.StatusBadRequest, 20001, "支付网关超时必须在 3 到 30 秒之间") + return + } + } + } + clearSet := map[string]bool{} + for _, key := range req.ClearSecrets { + spec, exists := allowed[key] + if !exists || spec.Input != "secret" { + fail(w, http.StatusBadRequest, 20001, "清除的配置项不是允许的密钥字段") + return + } + clearSet[key] = true + } + if group == "oauth" { + if err := a.validateAdminOAuthConfigValues(r.Context(), req.Values, clearSet); err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + defer func() { _ = tx.Rollback() }() + changed := []string{} + for key, value := range req.Values { + spec, exists := allowed[key] + if !exists { + continue + } + isSecret := spec.Input == "secret" + if isSecret && value == "" && !clearSet[key] { + continue + } + if isSecret && !clearSet[key] { + value, err = a.encryptSecret(value) + if err != nil { + fail(w, 500, 50001, "加密敏感配置失败") + return + } + } + if clearSet[key] { + value = "" + } + if _, err = tx.ExecContext(r.Context(), `UPDATE system_configs SET config_value=? WHERE config_key=?`, value, key); err != nil { + fail(w, 500, 50001, "保存失败") + return + } + changed = append(changed, key) + } + if err = tx.Commit(); err != nil { + fail(w, 500, 50001, "保存失败") + return + } + a.audit(r, "update", group+"_integration", 0, map[string]any{"changedKeys": changed}) + reply(w, map[string]any{"success": true, "changedKeys": changed}) +} + +func (a *App) adminTestIntegration(w http.ResponseWriter, r *http.Request) { + group := integrationGroup(r) + switch group { + case "sms": + if !a.configBool(r.Context(), "sms.enabled", false) { + fail(w, 400, 20001, "短信服务当前未启用") + return + } + provider := a.configPlain(r.Context(), "sms.provider", "debug") + if err := a.validateSMSProviderConfig(r.Context()); err != nil { + fail(w, 400, 20001, err.Error()) + return + } + reply(w, map[string]any{"success": true, "message": cloudSMSProviderName(provider) + "短信配置校验通过", "provider": provider}) + case "payment": + mode := a.configPlain(r.Context(), "payment.mode", "sandbox") + if a.config.Environment == "production" && mode != "live" { + fail(w, 400, 20001, "生产环境必须使用 live 支付模式") + return + } + if mode == "live" && !a.paymentGatewayConfigured(r.Context()) { + fail(w, 400, 20001, "统一支付网关配置不完整,请检查下单/退款/回调 HTTPS 地址、令牌和至少 32 位回调密钥") + return + } + channels := a.availablePaymentChannels(r.Context()) + if len(channels) == 0 { + fail(w, 400, 20001, "至少启用一个支付渠道") + return + } + if mode == "live" { + for _, channel := range channels { + if channel["configured"] != true { + fail(w, 400, 20001, fmt.Sprintf("%s 的生产参数不完整", channel["name"])) + return + } + } + } + reply(w, map[string]any{"success": true, "message": "支付配置校验通过", "mode": mode, "channels": channels}) + case "storage": + provider := a.configPlain(r.Context(), "storage.provider", "local") + if err := a.validateStorageProviderConfig(r.Context()); err != nil { + fail(w, 400, 20001, err.Error()) + return + } + reply(w, map[string]any{"success": true, "message": storageProviderName(provider) + "配置校验通过", "provider": provider}) + case "oauth": + adminProviders, err := a.enabledAdminOAuthProviders(r.Context()) + if err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + userProviders, err := a.enabledUserOAuthProviders(r.Context()) + if err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + if _, err = a.userOAuthFrontendURL(r.Context()); err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + if len(adminProviders) == 0 && len(userProviders) == 0 { + reply(w, map[string]any{"success": true, "message": "当前未启用第三方登录,登录页不会显示第三方入口", "providers": []any{}}) + return + } + items := make([]map[string]string, 0, len(adminProviders)+len(userProviders)) + for _, provider := range adminProviders { + items = append(items, map[string]string{"audience": "admin", "code": provider.Code, "name": provider.Name}) + } + for _, provider := range userProviders { + items = append(items, map[string]string{"audience": "user", "code": provider.Code, "name": provider.Name}) + } + reply(w, map[string]any{"success": true, "message": "第三方登录配置校验通过", "providers": items}) + default: + fail(w, http.StatusNotFound, 30001, "配置分组不存在") + } +} + +func (a *App) dispatchSMS(ctx context.Context, phone, scene, code string) error { + provider := a.configPlain(ctx, "sms.provider", "debug") + if err := a.validateSMSProviderConfig(ctx); err != nil { + return err + } + switch provider { + case "debug": + return nil + case "aliyun": + _, err := a.sendAliyunSMS(ctx, phone, scene, code) + return err + case "tencent": + _, err := a.sendTencentSMS(ctx, phone, scene, code) + return err + case "huawei": + _, err := a.sendHuaweiSMS(ctx, phone, scene, code) + return err + case "webhook": + return a.sendWebhookSMS(ctx, phone, scene, code) + default: + return fmt.Errorf("不支持的短信提供商") + } +} + +func (a *App) sendWebhookSMS(ctx context.Context, phone, scene, code string) error { + endpoint := a.configPlain(ctx, "sms.webhook_url", "") + if endpoint == "" { + return fmt.Errorf("短信 Webhook 未配置") + } + templateID, err := a.smsTemplateID(ctx, "webhook", scene) + if err != nil { + return err + } + payload, _ := json.Marshal(map[string]any{"phone": phone, "scene": scene, "code": code, "signName": a.configPlain(ctx, "sms.sign_name", "星遇社交"), "templateId": templateID}) + request, _ := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + request.Header.Set("Content-Type", "application/json") + if token := a.configPlain(ctx, "sms.webhook_token", ""); token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + client := &http.Client{Timeout: 8 * time.Second} + response, err := client.Do(request) + if err != nil { + return fmt.Errorf("短信网关连接失败: %w", err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("短信网关返回 HTTP %d", response.StatusCode) + } + return nil +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/media.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/media.go new file mode 100644 index 0000000..19d6b59 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/media.go @@ -0,0 +1,187 @@ +package app + +import ( + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +const maxUploadBytes int64 = 16 << 20 + +var mediaNamePattern = regexp.MustCompile(`^[0-9]+-[0-9]+\.(?:gif|jpe?g|png|webp|mp3|wav|amr|m4a)$`) + +func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) { + // Multipart boundaries and headers add a small amount of overhead. Keep the + // actual file limit at 16 MiB without rejecting a file that is exactly at + // that limit solely because of the multipart envelope. + r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+(1<<20)) + if err := r.ParseMultipartForm(maxUploadBytes); err != nil { + fail(w, http.StatusBadRequest, 20001, "媒体文件不得超过 16MB") + return + } + + file, fileHeader, err := r.FormFile("file") + if err != nil { + fail(w, http.StatusBadRequest, 20001, "请选择媒体文件") + return + } + defer file.Close() + + size, err := file.Seek(0, io.SeekEnd) + if err != nil || size <= 0 || size > maxUploadBytes { + fail(w, http.StatusBadRequest, 20001, "媒体文件大小无效或超过 16MB") + return + } + if _, err = file.Seek(0, io.SeekStart); err != nil { + fail(w, http.StatusBadRequest, 20001, "无法读取媒体文件") + return + } + header := make([]byte, min(size, 512)) + read, err := io.ReadFull(file, header) + if err != nil && err != io.ErrUnexpectedEOF { + fail(w, http.StatusBadRequest, 20001, "无法读取媒体文件") + return + } + header = header[:read] + extensions := map[string]string{ + "image/gif": ".gif", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "audio/mpeg": ".mp3", + "audio/mp3": ".mp3", + "audio/wav": ".wav", + "audio/x-wav": ".wav", + "audio/amr": ".amr", + "audio/mp4": ".m4a", + } + contentType := http.DetectContentType(header) + extension, ok := extensions[contentType] + if !ok { + fail(w, http.StatusBadRequest, 20001, "仅支持常见图片或语音格式") + return + } + + if _, err = file.Seek(0, io.SeekStart); err != nil { + fail(w, http.StatusBadRequest, 20001, "无法读取媒体文件") + return + } + if err = a.validateStorageProviderConfig(r.Context()); err != nil { + fail(w, http.StatusServiceUnavailable, 50001, "文件存储尚未正确配置") + return + } + + provider := a.configPlain(r.Context(), "storage.provider", "local") + name := fmt.Sprintf("%d-%d%s", current(r).ID, time.Now().UnixNano(), extension) + objectKey := name + if provider != "local" { + prefix := strings.Trim(a.configPlain(r.Context(), "storage.object_prefix", "media"), "/") + objectKey = fmt.Sprintf("%s/%s/%s", prefix, time.Now().Format("2006/01"), name) + } + mediaType := "audio" + if strings.HasPrefix(contentType, "image/") { + mediaType = "image" + } + + var bucket, publicURL string + var storage mediaObjectStorage + closeStorage := func() {} + if provider == "local" { + baseURL := strings.TrimSpace(a.configPlain(r.Context(), "storage.local.public_base_url", "")) + if baseURL == "" { + scheme := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]) + if scheme != "https" { + scheme = "http" + } + baseURL = fmt.Sprintf("%s://%s/uploads", scheme, r.Host) + } + publicURL = storagePublicURL(baseURL, objectKey) + } else { + storage, closeStorage, err = a.newMediaObjectStorage(r.Context()) + if err != nil { + log.Printf("storage provider init failed provider=%s error=%v", provider, err) + fail(w, http.StatusServiceUnavailable, 50001, "文件存储连接初始化失败") + return + } + defer closeStorage() + bucket = storage.Bucket() + publicURL = storagePublicURL(a.configPlain(r.Context(), "storage."+provider+".public_base_url", ""), objectKey) + } + if len(publicURL) > 500 || len(objectKey) > 500 { + fail(w, http.StatusServiceUnavailable, 50001, "文件存储公开地址过长") + return + } + + result, err := a.db.ExecContext(r.Context(), `INSERT INTO media_assets(owner_user_id,media_type,storage_provider,bucket,object_key,public_url,mime_type,file_size,moderation_status,status) VALUES(?,?,?,?,?,'',?,?,1,0)`, current(r).ID, mediaType, provider, bucket, objectKey, contentType, size) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建媒体记录失败") + return + } + mediaID, _ := result.LastInsertId() + cleanupRecord := func() { + _, _ = a.db.ExecContext(r.Context(), `DELETE FROM media_assets WHERE id=? AND status=0`, mediaID) + } + + if provider == "local" { + directory := a.configPlain(r.Context(), "storage.local.directory", a.config.MediaDir) + if err = os.MkdirAll(directory, 0o755); err == nil { + target := filepath.Join(directory, filepath.Base(objectKey)) + var destination *os.File + destination, err = os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err == nil { + _, err = io.Copy(destination, file) + closeErr := destination.Close() + if err == nil { + err = closeErr + } + } + if err != nil { + _ = os.Remove(target) + } + } + } else { + err = storage.Put(r.Context(), objectKey, contentType, size, file) + } + if err != nil { + cleanupRecord() + log.Printf("media upload failed provider=%s bucket=%s object=%s original=%q error=%v", provider, bucket, objectKey, filepath.Base(fileHeader.Filename), err) + fail(w, http.StatusBadGateway, 50001, "文件上传失败,请检查存储配置后重试") + return + } + if _, err = a.db.ExecContext(r.Context(), `UPDATE media_assets SET public_url=?,status=1 WHERE id=? AND status=0`, publicURL, mediaID); err != nil { + if provider == "local" { + _ = os.Remove(filepath.Join(a.configPlain(r.Context(), "storage.local.directory", a.config.MediaDir), filepath.Base(objectKey))) + } else { + _ = storage.Delete(r.Context(), objectKey) + } + cleanupRecord() + fail(w, http.StatusInternalServerError, 50001, "完成媒体记录失败") + return + } + reply(w, map[string]any{ + "id": mediaID, + "name": name, + "url": publicURL, + "provider": provider, + "objectKey": objectKey, + }) +} + +func (a *App) serveMedia(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + name := parts[len(parts)-1] + if !mediaNamePattern.MatchString(name) { + http.NotFound(w, r) + return + } + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Set("X-Content-Type-Options", "nosniff") + directory := a.configPlain(r.Context(), "storage.local.directory", a.config.MediaDir) + http.ServeFile(w, r, filepath.Join(directory, filepath.Base(name))) +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/membership.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/membership.go new file mode 100644 index 0000000..6dece38 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/membership.go @@ -0,0 +1,344 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +func (a *App) availablePaymentChannels(ctx context.Context) []map[string]any { + mode := a.configPlain(ctx, "payment.mode", "sandbox") + gatewayReady := mode == "sandbox" || a.paymentGatewayConfigured(ctx) + if a.config.Environment == "production" && mode == "sandbox" { + gatewayReady = false + } + channels := []map[string]any{} + if a.configBool(ctx, "payment.alipay.enabled", true) { + channels = append(channels, map[string]any{"code": "alipay", "name": "支付宝", "icon": "支", "configured": gatewayReady}) + } + if a.configBool(ctx, "payment.wechat.enabled", true) { + channels = append(channels, map[string]any{"code": "wechat", "name": "微信支付", "icon": "微", "configured": gatewayReady}) + } + return channels +} + +func (a *App) paymentChannels(w http.ResponseWriter, r *http.Request) { + reply(w, map[string]any{"mode": a.configPlain(r.Context(), "payment.mode", "sandbox"), "items": a.availablePaymentChannels(r.Context())}) +} + +type planView struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Level int `json:"level"` + DurationDays int `json:"durationDays"` + DailyActiveChatLimit int `json:"dailyActiveChatLimit"` + DailyLikeLimit int `json:"dailyLikeLimit"` + CanViewVisitors bool `json:"canViewVisitors"` + CanInvisibleVisit bool `json:"canInvisibleVisit"` + RecommendationWeight int `json:"recommendationWeight"` + PriceCent int `json:"priceCent"` + OriginalPriceCent int `json:"originalPriceCent"` + Status int `json:"status"` + SortOrder int `json:"sortOrder"` +} + +func (a *App) membershipPlans(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,code,name,level,duration_days,daily_active_chat_limit,daily_like_limit,can_view_visitors,can_invisible_visit,recommendation_weight,price_cent,original_price_cent,status,sort_order FROM membership_plans WHERE status=1 AND deleted_at IS NULL ORDER BY sort_order`) + if err != nil { + fail(w, 500, 50001, "查询套餐失败") + return + } + defer rows.Close() + items := []planView{} + for rows.Next() { + var item planView + _ = rows.Scan(&item.ID, &item.Code, &item.Name, &item.Level, &item.DurationDays, &item.DailyActiveChatLimit, &item.DailyLikeLimit, &item.CanViewVisitors, &item.CanInvisibleVisit, &item.RecommendationWeight, &item.PriceCent, &item.OriginalPriceCent, &item.Status, &item.SortOrder) + items = append(items, item) + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) membershipStatus(w http.ResponseWriter, r *http.Request) { + quota := a.dailyActiveChatQuota(r.Context(), current(r).ID) + entitlements := a.resolveMembershipEntitlements(r.Context(), current(r).ID) + var likeUsed int + _ = a.db.QueryRowContext(r.Context(), `SELECT used_count FROM user_daily_like_usage WHERE user_id=? AND usage_date=CURRENT_DATE()`, current(r).ID).Scan(&likeUsed) + likeRemaining := -1 + if entitlements.DailyLikeLimit > 0 { + likeRemaining = entitlements.DailyLikeLimit - likeUsed + if likeRemaining < 0 { + likeRemaining = 0 + } + } + entitlementView := map[string]any{"dailyActiveChatLimit": entitlements.DailyActiveChatLimit, "dailyLikeLimit": entitlements.DailyLikeLimit, "dailyLikeUsed": likeUsed, "dailyLikeRemaining": likeRemaining, "canViewVisitors": entitlements.CanViewVisitors, "canInvisibleVisit": entitlements.CanInvisibleVisit, "recommendationWeight": entitlements.RecommendationWeight} + var planName string + var level int + var expires time.Time + err := a.db.QueryRowContext(r.Context(), `SELECT p.name,p.level,s.expires_at FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id WHERE s.user_id=? AND s.status=1 AND s.started_at<=NOW(3) AND s.expires_at>NOW(3) ORDER BY p.level DESC,s.expires_at DESC LIMIT 1`, current(r).ID).Scan(&planName, &level, &expires) + if err != nil { + reply(w, map[string]any{"active": false, "dailyActiveChat": quota, "entitlements": entitlementView, "level": 0, "name": "普通用户"}) + return + } + reply(w, map[string]any{"active": true, "dailyActiveChat": quota, "entitlements": entitlementView, "level": level, "name": planName, "expiresAt": expires}) +} + +func (a *App) createOrder(w http.ResponseWriter, r *http.Request) { + var req struct { + PlanID int64 `json:"planId"` + Channel string `json:"channel"` + } + if decode(r, &req) != nil || req.PlanID == 0 { + fail(w, 400, 20001, "请选择套餐") + return + } + channels := a.availablePaymentChannels(r.Context()) + if req.Channel == "" && len(channels) > 0 { + req.Channel, _ = channels[0]["code"].(string) + } + channelAllowed := false + for _, channel := range channels { + if channel["code"] == req.Channel { + channelAllowed = channel["configured"] == true + } + } + if !channelAllowed { + fail(w, 400, 20001, "支付渠道未启用或配置不完整") + return + } + var price int + if a.db.QueryRowContext(r.Context(), `SELECT price_cent FROM membership_plans WHERE id=? AND status=1 AND deleted_at IS NULL`, req.PlanID).Scan(&price) != nil { + fail(w, 404, 30001, "套餐不存在") + return + } + orderNo := fmt.Sprintf("XY%d%d%s", time.Now().UnixMilli(), current(r).ID, randomToken()[:8]) + result, err := a.db.ExecContext(r.Context(), `INSERT INTO orders(order_no,user_id,product_type,product_id,amount_cent,status,channel)VALUES(?,?,'membership',?,?,'CREATED',?)`, orderNo, current(r).ID, req.PlanID, price, req.Channel) + if err != nil { + fail(w, 500, 50001, "创建订单失败") + return + } + id, _ := result.LastInsertId() + reply(w, map[string]any{"id": id, "orderNo": orderNo, "amountCent": price, "status": "CREATED", "channel": req.Channel}) +} + +func (a *App) payOrder(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "订单编号无效") + return + } + mode := a.configPlain(r.Context(), "payment.mode", "sandbox") + if mode != "sandbox" && mode != "live" { + fail(w, http.StatusServiceUnavailable, 50003, "支付模式配置无效") + return + } + if mode == "live" { + var orderNo, status, channel, subject, providerOrderNo, checkoutURL string + var amountCent int + var paymentPayload sql.NullString + err = a.db.QueryRowContext(r.Context(), `SELECT o.order_no,o.amount_cent,o.status,o.channel,COALESCE(p.name,'会员套餐'),o.provider_order_no,o.checkout_url,o.payment_payload FROM orders o LEFT JOIN membership_plans p ON p.id=o.product_id WHERE o.id=? AND o.user_id=? AND o.deleted_at IS NULL`, id, current(r).ID).Scan(&orderNo, &amountCent, &status, &channel, &subject, &providerOrderNo, &checkoutURL, &paymentPayload) + if err != nil { + fail(w, http.StatusNotFound, 30001, "订单不存在") + return + } + if status == "PAID" { + reply(w, map[string]any{"success": true, "status": "PAID", "mode": mode}) + return + } + if status != "CREATED" { + fail(w, http.StatusBadRequest, 20001, "当前订单状态无法支付") + return + } + if providerOrderNo != "" && (checkoutURL != "" || paymentPayload.Valid) { + var appPayload map[string]any + if paymentPayload.Valid && paymentPayload.String != "" { + _ = json.Unmarshal([]byte(paymentPayload.String), &appPayload) + } + reply(w, map[string]any{"success": true, "mode": mode, "status": status, "providerOrderNo": providerOrderNo, "checkoutUrl": checkoutURL, "appPayload": appPayload}) + return + } + gatewayResult, gatewayErr := a.createGatewayPayment(r.Context(), paymentGatewayOrder{OrderNo: orderNo, AmountCent: amountCent, Channel: channel, Subject: subject, UserID: current(r).ID}) + if gatewayErr != nil { + fail(w, http.StatusBadGateway, 50003, "支付网关下单失败") + return + } + payloadJSON := "" + if len(gatewayResult.AppPayload) > 0 { + encoded, _ := json.Marshal(gatewayResult.AppPayload) + payloadJSON = string(encoded) + } + if _, err = a.db.ExecContext(r.Context(), `UPDATE orders SET provider_order_no=?,checkout_url=?,payment_payload=? WHERE id=? AND user_id=? AND status='CREATED' AND provider_order_no=''`, gatewayResult.ProviderOrderNo, gatewayResult.CheckoutURL, payloadJSON, id, current(r).ID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存支付信息失败") + return + } + reply(w, map[string]any{"success": true, "mode": mode, "status": status, "providerOrderNo": gatewayResult.ProviderOrderNo, "checkoutUrl": gatewayResult.CheckoutURL, "appPayload": gatewayResult.AppPayload}) + return + } + if a.config.Environment == "production" { + fail(w, http.StatusServiceUnavailable, 50003, "生产环境禁止沙箱支付") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "支付失败") + return + } + defer func() { _ = tx.Rollback() }() + var userID, planID int64 + var amountCent int + var orderNo string + var status string + err = tx.QueryRowContext(r.Context(), `SELECT user_id,product_id,amount_cent,order_no,status FROM orders WHERE id=? AND user_id=? AND deleted_at IS NULL FOR UPDATE`, id, current(r).ID).Scan(&userID, &planID, &amountCent, &orderNo, &status) + if err != nil { + fail(w, 404, 30001, "订单不存在") + return + } + if status == "PAID" { + reply(w, map[string]any{"success": true, "status": "PAID"}) + return + } + if status != "CREATED" { + fail(w, 400, 20001, "当前订单状态无法支付") + return + } + var durationDays, level int + if err = tx.QueryRowContext(r.Context(), `SELECT duration_days,level FROM membership_plans WHERE id=? AND status=1 AND deleted_at IS NULL`, planID).Scan(&durationDays, &level); err != nil { + fail(w, 400, 20001, "会员套餐已下架") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='PAID',paid_at=NOW(3),paid_amount_cent=?,provider_order_no=?,payment_notified_at=NOW(3) WHERE id=?`, amountCent, "sandbox:"+orderNo, id); err == nil { + err = grantOrderMembershipTx(r.Context(), tx, id, userID, planID, durationDays, level) + } + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "支付入账失败") + return + } + reply(w, map[string]any{"success": true, "status": "PAID", "mode": mode}) +} + +func (a *App) orderStatus(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "订单编号无效") + return + } + var orderNo, status, channel, productName, refundReason string + var amountCent int + var paidAt, refundRequestedAt sql.NullTime + if err = a.db.QueryRowContext(r.Context(), `SELECT o.order_no,o.amount_cent,o.status,o.channel,o.paid_at,COALESCE(p.name,'已删除套餐'),o.refund_reason,o.refund_requested_at FROM orders o LEFT JOIN membership_plans p ON p.id=o.product_id WHERE o.id=? AND o.user_id=? AND o.deleted_at IS NULL`, id, current(r).ID).Scan(&orderNo, &amountCent, &status, &channel, &paidAt, &productName, &refundReason, &refundRequestedAt); err != nil { + fail(w, http.StatusNotFound, 30001, "订单不存在") + return + } + result := map[string]any{"id": id, "orderNo": orderNo, "amountCent": amountCent, "status": status, "channel": channel, "productName": productName, "refundReason": refundReason, "refundRequestedAt": nullableTime(refundRequestedAt)} + if paidAt.Valid { + result["paidAt"] = paidAt.Time + } + reply(w, result) +} + +func (a *App) myOrders(w http.ResponseWriter, r *http.Request) { + page, pageSize, offset := pageOptions(r) + var total int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM orders WHERE user_id=? AND deleted_at IS NULL`, current(r).ID).Scan(&total); err != nil { + fail(w, 500, 50001, "查询失败") + return + } + rows, err := a.db.QueryContext(r.Context(), `SELECT o.id,o.order_no,o.amount_cent,o.status,o.channel,o.created_at,COALESCE(p.name,'已删除套餐') FROM orders o LEFT JOIN membership_plans p ON p.id=o.product_id WHERE o.user_id=? AND o.deleted_at IS NULL ORDER BY o.created_at DESC LIMIT ? OFFSET ?`, current(r).ID, pageSize, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var orderNo, status, channel, name string + var amount int + var created time.Time + _ = rows.Scan(&id, &orderNo, &amount, &status, &channel, &created, &name) + items = append(items, map[string]any{"id": id, "orderNo": orderNo, "amountCent": amount, "status": status, "channel": channel, "createdAt": created, "productName": name}) + } + reply(w, map[string]any{"items": items, "page": page, "pageSize": pageSize, "total": total, "hasMore": offset+len(items) < total}) +} + +func (a *App) notifications(w http.ResponseWriter, r *http.Request) { + page, pageSize, offset := pageOptions(r) + notificationType := strings.TrimSpace(r.URL.Query().Get("type")) + where := ` WHERE user_id=?` + args := []any{current(r).ID} + if notificationType != "" { + where += ` AND type=?` + args = append(args, notificationType) + } + args = append(args, pageSize, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT id,type,title,content,biz_type,biz_id,read_at,created_at FROM notifications`+where+` ORDER BY created_at DESC LIMIT ? OFFSET ?`, args...) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var typ, title, content, bizType string + var bizID any + var readAt any + var created time.Time + _ = rows.Scan(&id, &typ, &title, &content, &bizType, &bizID, &readAt, &created) + items = append(items, map[string]any{"id": id, "type": typ, "title": title, "content": content, "bizType": bizType, "bizId": bizID, "readAt": readAt, "createdAt": created}) + } + var unread, total int + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM notifications WHERE user_id=? AND read_at IS NULL`, current(r).ID).Scan(&unread) + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM notifications`+where, args[:len(args)-2]...).Scan(&total) + rowsByType, _ := a.db.QueryContext(r.Context(), `SELECT type,COUNT(*) FROM notifications WHERE user_id=? AND read_at IS NULL GROUP BY type`, current(r).ID) + unreadByType := map[string]int{} + if rowsByType != nil { + defer rowsByType.Close() + for rowsByType.Next() { + var typ string + var count int + _ = rowsByType.Scan(&typ, &count) + unreadByType[typ] = count + } + } + reply(w, map[string]any{"items": items, "page": page, "pageSize": pageSize, "total": total, "unread": unread, "unreadByType": unreadByType, "hasMore": offset+len(items) < total}) +} +func (a *App) readAllNotifications(w http.ResponseWriter, r *http.Request) { + _, _ = a.db.ExecContext(r.Context(), `UPDATE notifications SET read_at=NOW(3) WHERE user_id=? AND read_at IS NULL`, current(r).ID) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) appConfig(w http.ResponseWriter, r *http.Request) { + rows, _ := a.db.QueryContext(r.Context(), `SELECT config_key,config_value,value_type FROM system_configs WHERE value_type<>'secret' AND config_key LIKE 'app.%'`) + configs := map[string]any{} + if rows != nil { + defer rows.Close() + for rows.Next() { + var key, value, typ string + _ = rows.Scan(&key, &value, &typ) + configs[key] = value + } + } + platform := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("platform"))) + if platform != "android" && platform != "ios" && platform != "h5" { + platform = "h5" + } + latest := map[string]any{} + var version, downloadURL, notes string + var build int + var force bool + if a.db.QueryRowContext(r.Context(), `SELECT version,build_number,force_update,download_url,release_notes FROM app_versions WHERE platform=? AND status=1 ORDER BY build_number DESC LIMIT 1`, platform).Scan(&version, &build, &force, &downloadURL, ¬es) == nil { + latest = map[string]any{"version": version, "buildNumber": build, "forceUpdate": force, "downloadUrl": downloadURL, "releaseNotes": notes} + } + features := map[string]bool{ + "nearby": a.configBool(r.Context(), "app.features.nearby", true), + "feed": a.configBool(r.Context(), "app.features.feed", true), + "membership": a.configBool(r.Context(), "app.features.membership", true), + "im": a.configBool(r.Context(), "app.features.im", true), + } + reply(w, map[string]any{"configs": configs, "platform": platform, "features": features, "maintenance": map[string]any{"enabled": a.configBool(r.Context(), "app.maintenance.enabled", false), "message": a.configPlain(r.Context(), "app.maintenance.message", "系统维护中,请稍后再试")}, "legal": map[string]string{"userAgreementVersion": a.configPlain(r.Context(), "legal.user_agreement_version", "1.0"), "privacyPolicyVersion": a.configPlain(r.Context(), "legal.privacy_policy_version", "1.0"), "operatorName": a.configPlain(r.Context(), "legal.operator_name", ""), "contact": a.configPlain(r.Context(), "legal.contact", ""), "effectiveDate": a.configPlain(r.Context(), "legal.effective_date", ""), "userAgreementUrl": a.configPlain(r.Context(), "legal.user_agreement_url", ""), "privacyPolicyUrl": a.configPlain(r.Context(), "legal.privacy_policy_url", "")}, "minVersion": a.configPlain(r.Context(), "app.min_version."+platform, "1.0.0"), "latest": latest}) +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/membership_limits.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/membership_limits.go new file mode 100644 index 0000000..16e5323 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/membership_limits.go @@ -0,0 +1,122 @@ +package app + +import ( + "context" + "database/sql" + "fmt" + "strconv" +) + +const defaultFreeDailyActiveChatLimit = 5 + +type rowQuerier interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +type dailyActiveChatLimitError struct { + Limit int +} + +func (e *dailyActiveChatLimitError) Error() string { + return fmt.Sprintf("今日主动聊天人数已达上限(%d人),回复收到的消息不受此限制", e.Limit) +} + +func (a *App) resolveDailyActiveChatLimit(ctx context.Context, queryer rowQuerier, userID int64) int { + var limit int + err := queryer.QueryRowContext(ctx, `SELECT p.daily_active_chat_limit + FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id + WHERE s.user_id=? AND s.status=1 AND s.started_at<=NOW(3) AND s.expires_at>NOW(3) + ORDER BY p.level DESC,s.expires_at DESC LIMIT 1`, userID).Scan(&limit) + if err == nil { + return limit + } + + var raw string + if err = queryer.QueryRowContext(ctx, `SELECT config_value FROM system_configs WHERE config_key='membership.free_daily_active_chat_limit'`).Scan(&raw); err == nil { + if parsed, parseErr := strconv.Atoi(raw); parseErr == nil && parsed >= 0 { + return parsed + } + } + return defaultFreeDailyActiveChatLimit +} + +func dailyActiveChatQuotaView(limit, used int) map[string]any { + remaining := -1 + unlimited := limit == 0 + if !unlimited { + remaining = limit - used + if remaining < 0 { + remaining = 0 + } + } + return map[string]any{ + "limit": limit, + "remaining": remaining, + "unlimited": unlimited, + "used": used, + } +} + +func (a *App) dailyActiveChatQuota(ctx context.Context, userID int64) map[string]any { + limit := a.resolveDailyActiveChatLimit(ctx, a.db, userID) + var used int + _ = a.db.QueryRowContext(ctx, `SELECT used_count FROM im_daily_active_chat_usage WHERE user_id=? AND usage_date=CURRENT_DATE()`, userID).Scan(&used) + return dailyActiveChatQuotaView(limit, used) +} + +func (a *App) reserveDailyActiveChat(ctx context.Context, tx *sql.Tx, conversationID, senderID int64) error { + var user1ID, user2ID int64 + err := tx.QueryRowContext(ctx, `SELECT user1_id,user2_id FROM im_direct_conversations WHERE conversation_id=?`, conversationID).Scan(&user1ID, &user2ID) + if err == sql.ErrNoRows { + return nil + } + if err != nil { + return err + } + targetUserID := user1ID + if senderID == user1ID { + targetUserID = user2ID + } else if senderID != user2ID { + return fmt.Errorf("不是会话成员") + } + + var inboundToday int + if err = tx.QueryRowContext(ctx, `SELECT EXISTS( + SELECT 1 FROM im_messages + WHERE conversation_id=? AND sender_id=? + AND created_at>=CURRENT_DATE() AND created_at 0 && used >= limit { + return &dailyActiveChatLimitError{Limit: limit} + } + if _, err = tx.ExecContext(ctx, `INSERT INTO im_daily_active_chat_targets(user_id,target_user_id,usage_date,conversation_id) VALUES(?,?,CURRENT_DATE(),?)`, senderID, targetUserID, conversationID); err != nil { + return err + } + _, err = tx.ExecContext(ctx, `UPDATE im_daily_active_chat_usage SET used_count=used_count+1 WHERE user_id=? AND usage_date=CURRENT_DATE()`, senderID) + return err +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/membership_limits_test.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/membership_limits_test.go new file mode 100644 index 0000000..0afd489 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/membership_limits_test.go @@ -0,0 +1,25 @@ +package app + +import "testing" + +func TestDailyActiveChatQuotaView(t *testing.T) { + limited := dailyActiveChatQuotaView(20, 7) + if limited["remaining"] != 13 || limited["unlimited"] != false { + t.Fatalf("unexpected limited quota: %#v", limited) + } + exhausted := dailyActiveChatQuotaView(5, 8) + if exhausted["remaining"] != 0 { + t.Fatalf("remaining quota must not be negative: %#v", exhausted) + } + unlimited := dailyActiveChatQuotaView(0, 99) + if unlimited["remaining"] != -1 || unlimited["unlimited"] != true { + t.Fatalf("unexpected unlimited quota: %#v", unlimited) + } +} + +func TestDailyActiveChatLimitError(t *testing.T) { + err := (&dailyActiveChatLimitError{Limit: 20}).Error() + if err != "今日主动聊天人数已达上限(20人),回复收到的消息不受此限制" { + t.Fatalf("unexpected quota message: %s", err) + } +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/payment_gateway.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/payment_gateway.go new file mode 100644 index 0000000..0abef90 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/payment_gateway.go @@ -0,0 +1,347 @@ +package app + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type paymentGatewayOrder struct { + OrderNo string + AmountCent int + Channel string + Subject string + UserID int64 +} + +type paymentGatewayResult struct { + ProviderOrderNo string `json:"providerOrderNo"` + CheckoutURL string `json:"checkoutUrl"` + AppPayload map[string]any `json:"appPayload"` +} + +type paymentNotifyRequest struct { + EventID string `json:"eventId"` + OrderNo string `json:"orderNo"` + Channel string `json:"channel"` + ProviderOrderNo string `json:"providerOrderNo"` + Status string `json:"status"` + AmountCent int `json:"amountCent"` +} + +func validHTTPSURL(raw string) bool { + parsed, err := url.Parse(strings.TrimSpace(raw)) + return err == nil && parsed.Scheme == "https" && parsed.Host != "" +} + +func (a *App) paymentGatewayConfigured(ctx context.Context) bool { + createURL := a.configPlain(ctx, "payment.gateway.create_url", "") + refundURL := a.configPlain(ctx, "payment.gateway.refund_url", "") + notifyURL := a.configPlain(ctx, "payment.gateway.notify_url", "") + secret := a.configPlain(ctx, "payment.gateway.notify_secret", "") + token := a.configPlain(ctx, "payment.gateway.token", "") + if createURL == "" || refundURL == "" || notifyURL == "" || len(secret) < 32 || token == "" { + return false + } + if a.config.Environment == "production" && (!validHTTPSURL(createURL) || !validHTTPSURL(refundURL) || !validHTTPSURL(notifyURL)) { + return false + } + return true +} + +func (a *App) createGatewayPayment(ctx context.Context, order paymentGatewayOrder) (paymentGatewayResult, error) { + if !a.paymentGatewayConfigured(ctx) { + return paymentGatewayResult{}, fmt.Errorf("payment gateway is not completely configured") + } + payload, err := json.Marshal(map[string]any{ + "orderNo": order.OrderNo, "amountCent": order.AmountCent, "currency": "CNY", + "channel": order.Channel, "subject": order.Subject, "userId": order.UserID, + "notifyUrl": a.configPlain(ctx, "payment.gateway.notify_url", ""), + "returnUrl": a.configPlain(ctx, "payment.gateway.return_url", ""), + }) + if err != nil { + return paymentGatewayResult{}, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.configPlain(ctx, "payment.gateway.create_url", ""), bytes.NewReader(payload)) + if err != nil { + return paymentGatewayResult{}, err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", "Bearer "+a.configPlain(ctx, "payment.gateway.token", "")) + request.Header.Set("Idempotency-Key", order.OrderNo) + + timeout, _ := strconv.Atoi(a.configPlain(ctx, "payment.gateway.timeout_seconds", "10")) + if timeout < 3 || timeout > 30 { + timeout = 10 + } + response, err := (&http.Client{Timeout: time.Duration(timeout) * time.Second}).Do(request) + if err != nil { + return paymentGatewayResult{}, fmt.Errorf("payment gateway request failed: %w", err) + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, 512<<10)) + if err != nil { + return paymentGatewayResult{}, fmt.Errorf("read payment gateway response: %w", err) + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return paymentGatewayResult{}, fmt.Errorf("payment gateway returned HTTP %d", response.StatusCode) + } + + var envelope struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + } + var result paymentGatewayResult + if err = json.Unmarshal(body, &envelope); err == nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" { + if envelope.Code != 0 { + return paymentGatewayResult{}, fmt.Errorf("payment gateway rejected request: %s", envelope.Message) + } + err = json.Unmarshal(envelope.Data, &result) + } else { + err = json.Unmarshal(body, &result) + } + if err != nil { + return paymentGatewayResult{}, fmt.Errorf("invalid payment gateway response: %w", err) + } + if strings.TrimSpace(result.ProviderOrderNo) == "" || (result.CheckoutURL == "" && len(result.AppPayload) == 0) { + return paymentGatewayResult{}, fmt.Errorf("payment gateway response is incomplete") + } + if result.CheckoutURL != "" && a.config.Environment == "production" && !validHTTPSURL(result.CheckoutURL) { + return paymentGatewayResult{}, fmt.Errorf("payment gateway returned a non-HTTPS checkout URL") + } + return result, nil +} + +func (a *App) paymentNotify(w http.ResponseWriter, r *http.Request) { + if !a.paymentGatewayConfigured(r.Context()) { + fail(w, http.StatusServiceUnavailable, 50003, "支付网关未配置") + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 128<<10)) + if err != nil || len(body) == 0 { + fail(w, http.StatusBadRequest, 20001, "支付通知内容无效") + return + } + timestamp := strings.TrimSpace(r.Header.Get("X-Xingyu-Timestamp")) + signature := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("X-Xingyu-Signature"), "sha256=")) + unixSeconds, parseErr := strconv.ParseInt(timestamp, 10, 64) + if parseErr != nil || time.Since(time.Unix(unixSeconds, 0)) > 5*time.Minute || time.Until(time.Unix(unixSeconds, 0)) > 5*time.Minute { + fail(w, http.StatusUnauthorized, 10006, "支付通知时间戳无效") + return + } + mac := hmac.New(sha256.New, []byte(a.configPlain(r.Context(), "payment.gateway.notify_secret", ""))) + _, _ = mac.Write([]byte(timestamp + ".")) + _, _ = mac.Write(body) + expected := hex.EncodeToString(mac.Sum(nil)) + if len(signature) != len(expected) || subtle.ConstantTimeCompare([]byte(strings.ToLower(signature)), []byte(expected)) != 1 { + fail(w, http.StatusUnauthorized, 10006, "支付通知签名无效") + return + } + var notice paymentNotifyRequest + if json.Unmarshal(body, ¬ice) != nil || notice.EventID == "" || notice.OrderNo == "" || notice.Channel == "" || notice.ProviderOrderNo == "" || notice.AmountCent <= 0 { + fail(w, http.StatusBadRequest, 20001, "支付通知字段不完整") + return + } + notice.Status = strings.ToUpper(strings.TrimSpace(notice.Status)) + if notice.Status != "PAID" && notice.Status != "FAILED" && notice.Status != "CLOSED" && notice.Status != "REFUNDED" && notice.Status != "REFUND_FAILED" { + fail(w, http.StatusBadRequest, 20001, "支付通知状态无效") + return + } + if err = a.settlePayment(r.Context(), notice, string(body)); err != nil { + fail(w, http.StatusConflict, 20001, err.Error()) + return + } + reply(w, map[string]any{"success": true, "eventId": notice.EventID}) +} + +func (a *App) settlePayment(ctx context.Context, notice paymentNotifyRequest, raw string) error { + tx, err := a.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(ctx, `INSERT IGNORE INTO payment_events(event_id,order_no,channel,provider_order_no,event_status,amount_cent,raw_payload) VALUES(?,?,?,?,?,?,?)`, notice.EventID, notice.OrderNo, notice.Channel, notice.ProviderOrderNo, notice.Status, notice.AmountCent, raw) + if err != nil { + return err + } + affected, _ := result.RowsAffected() + if affected == 0 { + return tx.Commit() + } + + var orderID, userID, planID int64 + var amountCent int + var status, channel string + if err = tx.QueryRowContext(ctx, `SELECT id,user_id,product_id,amount_cent,status,channel FROM orders WHERE order_no=? AND deleted_at IS NULL FOR UPDATE`, notice.OrderNo).Scan(&orderID, &userID, &planID, &amountCent, &status, &channel); err != nil { + return fmt.Errorf("order does not exist") + } + if channel != notice.Channel || amountCent != notice.AmountCent { + return fmt.Errorf("payment amount or channel does not match the order") + } + if notice.Status == "REFUNDED" { + if status == "REFUNDED" { + return tx.Commit() + } + if status != "PAID" && status != "REFUNDING" { + return fmt.Errorf("order status does not allow refund") + } + if _, err = tx.ExecContext(ctx, `UPDATE orders SET status='REFUNDED',payment_notified_at=NOW(3) WHERE id=?`, orderID); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1 AND source IN (?,?)`, userID, fmt.Sprintf("order:%d", orderID), fmt.Sprintf("admin_order:%d", orderID)); err != nil { + return err + } + if err = a.recomputeMembershipTx(ctx, tx, userID); err != nil { + return err + } + return tx.Commit() + } + if notice.Status == "REFUND_FAILED" { + if status == "REFUNDING" { + if _, err = tx.ExecContext(ctx, `UPDATE orders SET status='PAID',payment_notified_at=NOW(3) WHERE id=?`, orderID); err != nil { + return err + } + } + return tx.Commit() + } + if notice.Status == "PAID" && (status == "PAID" || status == "REFUNDING" || status == "REFUNDED") { + return tx.Commit() + } + if status != "CREATED" { + return fmt.Errorf("order status does not allow payment") + } + if notice.Status != "PAID" { + return tx.Commit() + } + + var durationDays, level int + if err = tx.QueryRowContext(ctx, `SELECT duration_days,level FROM membership_plans WHERE id=? AND deleted_at IS NULL`, planID).Scan(&durationDays, &level); err != nil { + return fmt.Errorf("membership plan does not exist") + } + if _, err = tx.ExecContext(ctx, `UPDATE orders SET status='PAID',paid_at=NOW(3),paid_amount_cent=?,provider_order_no=?,payment_notified_at=NOW(3) WHERE id=?`, notice.AmountCent, notice.ProviderOrderNo, orderID); err != nil { + return err + } + if err = grantOrderMembershipTx(ctx, tx, orderID, userID, planID, durationDays, level); err != nil { + return err + } + return tx.Commit() +} + +func (a *App) createGatewayRefund(ctx context.Context, orderNo, providerOrderNo string, amountCent int) error { + endpoint := a.configPlain(ctx, "payment.gateway.refund_url", "") + if endpoint == "" || (a.config.Environment == "production" && !validHTTPSURL(endpoint)) { + return fmt.Errorf("payment refund gateway is not configured") + } + payload, err := json.Marshal(map[string]any{ + "orderNo": orderNo, "providerOrderNo": providerOrderNo, "amountCent": amountCent, + "currency": "CNY", "reason": "admin_requested", + }) + if err != nil { + return err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", "Bearer "+a.configPlain(ctx, "payment.gateway.token", "")) + request.Header.Set("Idempotency-Key", "refund:"+orderNo) + timeout, _ := strconv.Atoi(a.configPlain(ctx, "payment.gateway.timeout_seconds", "10")) + if timeout < 3 || timeout > 30 { + timeout = 10 + } + response, err := (&http.Client{Timeout: time.Duration(timeout) * time.Second}).Do(request) + if err != nil { + return fmt.Errorf("refund gateway request failed: %w", err) + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, 256<<10)) + if err != nil { + return err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("refund gateway returned HTTP %d", response.StatusCode) + } + var gatewayResponse struct { + Code *int `json:"code"` + Success *bool `json:"success"` + Message string `json:"message"` + } + if len(bytes.TrimSpace(body)) > 0 { + if err = json.Unmarshal(body, &gatewayResponse); err != nil { + return fmt.Errorf("refund gateway returned invalid JSON: %w", err) + } + if (gatewayResponse.Code != nil && *gatewayResponse.Code != 0) || (gatewayResponse.Success != nil && !*gatewayResponse.Success) { + return fmt.Errorf("refund gateway rejected request: %s", gatewayResponse.Message) + } + } + return nil +} + +func (a *App) requestLiveRefund(w http.ResponseWriter, r *http.Request, orderID int64) { + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建退款申请失败") + return + } + defer func() { _ = tx.Rollback() }() + var userID int64 + var amountCent int + var orderNo, providerOrderNo, status string + if err = tx.QueryRowContext(r.Context(), `SELECT user_id,amount_cent,order_no,provider_order_no,status FROM orders WHERE id=? AND deleted_at IS NULL FOR UPDATE`, orderID).Scan(&userID, &amountCent, &orderNo, &providerOrderNo, &status); err != nil { + fail(w, http.StatusNotFound, 30001, "订单不存在") + return + } + if status != "PAID" && status != "REFUND_REQUESTED" && status != "REFUNDING" { + fail(w, http.StatusBadRequest, 20001, "只有已支付、用户已申请退款或退款处理中的订单可发起退款") + return + } + if providerOrderNo == "" { + fail(w, http.StatusBadRequest, 20001, "订单缺少支付渠道流水号,不能自动退款") + return + } + if status == "PAID" || status == "REFUND_REQUESTED" { + if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='REFUNDING' WHERE id=?`, orderID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新退款状态失败") + return + } + } + if err = tx.Commit(); err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建退款申请失败") + return + } + if err = a.createGatewayRefund(r.Context(), orderNo, providerOrderNo, amountCent); err != nil { + a.audit(r, "refund_request_failed", "order", orderID, map[string]any{"userId": userID, "error": err.Error()}) + fail(w, http.StatusBadGateway, 50003, "退款网关请求失败,订单已保留为退款处理中,可安全重试") + return + } + a.audit(r, "refund_requested", "order", orderID, map[string]any{"userId": userID, "amountCent": amountCent}) + reply(w, map[string]any{"success": true, "status": "REFUNDING"}) +} + +func grantOrderMembershipTx(ctx context.Context, tx *sql.Tx, orderID, userID, planID int64, durationDays, level int) error { + var base time.Time + if err := tx.QueryRowContext(ctx, `SELECT GREATEST(NOW(3),COALESCE(MAX(expires_at),NOW(3))) FROM subscriptions WHERE user_id=? AND status=1 AND expires_at>NOW(3)`, userID).Scan(&base); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(?,?,?,1,NOW(3),DATE_ADD(?,INTERVAL ? DAY))`, userID, planID, fmt.Sprintf("order:%d", orderID), base, durationDays); err != nil { + return err + } + _, err := tx.ExecContext(ctx, `UPDATE user_profiles SET is_vip=1,vip_level=GREATEST(vip_level,?) WHERE user_id=?`, level, userID) + return err +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/public_auth.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/public_auth.go new file mode 100644 index 0000000..896986f --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/public_auth.go @@ -0,0 +1,524 @@ +package app + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/binary" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type authRequest struct { + Phone string `json:"phone"` + Password string `json:"password"` + Code string `json:"code"` + Nickname string `json:"nickname"` + DeviceID string `json:"deviceId"` + Scene string `json:"scene"` +} + +func (a *App) sendSMS(w http.ResponseWriter, r *http.Request) { + var req authRequest + if err := decode(r, &req); err != nil || !validPhone(req.Phone) { + fail(w, http.StatusBadRequest, 20001, "请输入正确的手机号") + return + } + if !a.configBool(r.Context(), "sms.enabled", true) { + fail(w, http.StatusServiceUnavailable, 50002, "短信服务暂未开放") + return + } + if req.Scene == "" { + req.Scene = "login" + } + if req.Scene != "login" && req.Scene != "register" && req.Scene != "reset" && req.Scene != "change_phone" { + fail(w, http.StatusBadRequest, 20001, "验证码场景无效") + return + } + phone := strings.TrimSpace(req.Phone) + if !a.rateLimit(w, r, "sms_ip", clientIP(r), 20, time.Hour) || !a.rateLimit(w, r, "sms_phone", phone, 5, time.Hour) { + return + } + var recent int + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM sms_verification_codes WHERE phone_hash=? AND scene=? AND created_at>DATE_SUB(NOW(3),INTERVAL 60 SECOND)`, phoneHash(req.Phone), req.Scene).Scan(&recent) + if recent > 0 { + fail(w, http.StatusTooManyRequests, 20002, "请稍后再获取验证码") + return + } + provider := a.configPlain(r.Context(), "sms.provider", "debug") + if a.config.Environment == "production" && provider == "debug" { + fail(w, http.StatusServiceUnavailable, 50002, "生产环境禁止使用调试短信服务") + return + } + code := a.configPlain(r.Context(), "sms.debug_code", "123456") + if provider != "debug" { + var buffer [4]byte + _, _ = rand.Read(buffer[:]) + code = fmt.Sprintf("%06d", binary.BigEndian.Uint32(buffer[:])%1_000_000) + } + if err := a.dispatchSMS(r.Context(), phone, req.Scene, code); err != nil { + fail(w, http.StatusBadGateway, 50002, err.Error()) + return + } + expires, _ := strconv.Atoi(a.configPlain(r.Context(), "sms.expire_seconds", "300")) + if expires < 60 || expires > 1800 { + expires = 300 + } + codeHash := sha256.Sum256([]byte(code)) + _, err := a.db.ExecContext(r.Context(), `INSERT INTO sms_verification_codes(phone_hash,scene,code_hash,expires_at)VALUES(?,?,?,DATE_ADD(NOW(3),INTERVAL ? SECOND))`, phoneHash(req.Phone), req.Scene, codeHash[:], expires) + if err != nil { + fail(w, 500, 50001, "保存验证码失败") + return + } + data := map[string]any{"expiresIn": expires, "provider": provider} + if provider == "debug" { + data["debugCode"] = code + } + reply(w, data) +} + +func (a *App) register(w http.ResponseWriter, r *http.Request) { + var req authRequest + if err := decode(r, &req); err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + if !validPhone(req.Phone) || !validUserPassword(req.Password) || strings.TrimSpace(req.Nickname) == "" || len([]rune(strings.TrimSpace(req.Nickname))) > 50 { + fail(w, http.StatusBadRequest, 20001, "密码需为 8-72 位并同时包含字母和数字") + return + } + if !a.rateLimit(w, r, "register_ip", clientIP(r), 20, 10*time.Minute) || !a.rateLimit(w, r, "register_phone", strings.TrimSpace(req.Phone), 10, 10*time.Minute) { + return + } + if !a.consumeSMSCode(r, req.Phone, "register", req.Code) { + fail(w, http.StatusBadRequest, 20001, "验证码错误或已过期") + return + } + hash, err := hashPassword(req.Password) + if err != nil { + fail(w, 500, 50001, "创建账号失败") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "创建账号失败") + return + } + defer func() { _ = tx.Rollback() }() + publicID := fmt.Sprintf("XY%d%s", time.Now().UnixMilli(), randomToken()[:5]) + phoneCipher, encryptErr := a.encryptPhone(req.Phone) + if encryptErr != nil { + fail(w, 500, 50001, "加密账号信息失败") + return + } + result, err := tx.ExecContext(r.Context(), `INSERT INTO users (public_id,country_code,phone_hash,phone_cipher,password_hash) VALUES (?,'+86',?,?,?)`, publicID, phoneHash(req.Phone), phoneCipher, hash) + if err != nil { + fail(w, http.StatusConflict, 20001, "该手机号已注册") + return + } + userID, err := result.LastInsertId() + if err != nil { + fail(w, 500, 50001, "创建账号失败") + return + } + _, err = tx.ExecContext(r.Context(), `INSERT INTO user_profiles (user_id,nickname,bio,profile_score,last_active_at) VALUES (?,?, '遇见更好的陌生人',30,NOW(3))`, userID, req.Nickname) + if err != nil { + fail(w, 500, 50001, "创建资料失败") + return + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_privacy_settings (user_id) VALUES (?)`, userID); err != nil { + fail(w, 500, 50001, "创建账号失败") + return + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_notification_settings (user_id) VALUES (?)`, userID); err != nil { + fail(w, 500, 50001, "创建账号失败") + return + } + if err := tx.Commit(); err != nil { + fail(w, 500, 50001, "创建账号失败") + return + } + a.finishLogin(w, r, userID, req.Nickname, req.DeviceID) +} + +func (a *App) loginPassword(w http.ResponseWriter, r *http.Request) { + var req authRequest + if err := decode(r, &req); err != nil { + fail(w, 400, 20001, err.Error()) + return + } + if !validPhone(req.Phone) || req.Password == "" { + fail(w, http.StatusUnauthorized, 10001, "手机号或密码错误") + return + } + if !a.rateLimit(w, r, "login_ip", clientIP(r), 60, 10*time.Minute) || !a.rateLimit(w, r, "login_phone", strings.TrimSpace(req.Phone), 10, 10*time.Minute) { + return + } + var id int64 + var hash, nickname string + var status int + err := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.password_hash,u.status,p.nickname FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.phone_hash=? AND u.deleted_at IS NULL`, phoneHash(req.Phone)).Scan(&id, &hash, &status, &nickname) + if err != nil || !checkPassword(hash, req.Password) { + fail(w, http.StatusUnauthorized, 10001, "手机号或密码错误") + return + } + if status != 1 { + fail(w, http.StatusForbidden, 10006, "账号当前不可用") + return + } + a.finishLogin(w, r, id, nickname, req.DeviceID) +} + +func (a *App) loginSMS(w http.ResponseWriter, r *http.Request) { + var req authRequest + if err := decode(r, &req); err != nil || !validPhone(req.Phone) || len(req.Code) != 6 { + fail(w, 400, 20001, "验证码格式错误") + return + } + if !a.rateLimit(w, r, "sms_login_ip", clientIP(r), 30, 10*time.Minute) || !a.rateLimit(w, r, "sms_login_phone", strings.TrimSpace(req.Phone), 10, 10*time.Minute) { + return + } + if !a.consumeSMSCode(r, req.Phone, "login", req.Code) { + fail(w, 400, 20001, "验证码错误或已过期") + return + } + var id int64 + var nickname string + if err := a.db.QueryRowContext(r.Context(), `SELECT u.id,p.nickname FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.phone_hash=? AND u.status=1`, phoneHash(req.Phone)).Scan(&id, &nickname); err != nil { + fail(w, http.StatusUnauthorized, 10001, "账号不存在") + return + } + a.finishLogin(w, r, id, nickname, req.DeviceID) +} + +func (a *App) resetPassword(w http.ResponseWriter, r *http.Request) { + var req authRequest + if err := decode(r, &req); err != nil || !validPhone(req.Phone) || len(req.Code) != 6 || !validUserPassword(req.Password) { + fail(w, 400, 20001, "密码需为 8-72 位并同时包含字母和数字") + return + } + if !a.rateLimit(w, r, "password_reset_ip", clientIP(r), 20, 10*time.Minute) || !a.rateLimit(w, r, "password_reset_phone", strings.TrimSpace(req.Phone), 10, 10*time.Minute) { + return + } + if !a.consumeSMSCode(r, req.Phone, "reset", req.Code) { + fail(w, 400, 20001, "验证码错误或已过期") + return + } + hash, _ := hashPassword(req.Password) + result, err := a.db.ExecContext(r.Context(), `UPDATE users SET password_hash=? WHERE phone_hash=? AND deleted_at IS NULL`, hash, phoneHash(req.Phone)) + if err != nil { + fail(w, 500, 50001, "重置密码失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 404, 30001, "账号不存在") + return + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id IN (SELECT id FROM users WHERE phone_hash=?) AND revoked_at IS NULL`, phoneHash(req.Phone)) + _, _ = a.db.ExecContext(r.Context(), `INSERT INTO user_security_controls(user_id,token_version,force_logout_at,password_reset_at) SELECT id,1,NOW(3),NOW(3) FROM users WHERE phone_hash=? ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=VALUES(force_logout_at),password_reset_at=VALUES(password_reset_at)`, phoneHash(req.Phone)) + var resetUserID int64 + if a.db.QueryRowContext(r.Context(), `SELECT id FROM users WHERE phone_hash=?`, phoneHash(req.Phone)).Scan(&resetUserID) == nil { + a.hub.disconnect(resetUserID) + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) consumeSMSCode(r *http.Request, phone, scene, code string) bool { + if phone == "" || code == "" { + return false + } + var id int64 + var expected []byte + err := a.db.QueryRowContext(r.Context(), `SELECT id,code_hash FROM sms_verification_codes WHERE phone_hash=? AND scene=? AND used_at IS NULL AND expires_at>NOW(3) ORDER BY id DESC LIMIT 1`, phoneHash(phone), scene).Scan(&id, &expected) + if err != nil { + return false + } + actual := sha256.Sum256([]byte(code)) + if !bytesEqual(expected, actual[:]) { + return false + } + result, err := a.db.ExecContext(r.Context(), `UPDATE sms_verification_codes SET used_at=NOW(3) WHERE id=? AND used_at IS NULL`, id) + if err != nil { + return false + } + affected, _ := result.RowsAffected() + return affected == 1 +} + +func bytesEqual(left, right []byte) bool { + if len(left) != len(right) { + return false + } + var different byte + for index := range left { + different |= left[index] ^ right[index] + } + return different == 0 +} + +func (a *App) finishLogin(w http.ResponseWriter, r *http.Request, id int64, nickname, deviceID string) { + if deviceID == "" { + deviceID = "web-h5" + } + if len(deviceID) > 100 { + fail(w, http.StatusBadRequest, 20001, "设备标识过长") + return + } + accessToken, err := a.token(id, "user", nickname, 30*time.Minute) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建登录令牌失败") + return + } + refresh := randomToken() + refreshHash := sha256.Sum256([]byte(refresh)) + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建登录会话失败") + return + } + defer func() { _ = tx.Rollback() }() + if _, err = tx.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND device_id=? AND revoked_at IS NULL`, id, deviceID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新设备会话失败") + return + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_sessions (user_id,device_id,refresh_token_hash,expires_at) VALUES (?,?,?,DATE_ADD(NOW(3), INTERVAL 60 DAY))`, id, deviceID, refreshHash[:]); err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建登录会话失败") + return + } + platform := strings.TrimSpace(r.Header.Get("X-Device-Platform")) + model := strings.TrimSpace(r.Header.Get("X-Device-Model")) + osVersion := strings.TrimSpace(r.Header.Get("X-OS-Version")) + if decoded, decodeErr := url.QueryUnescape(model); decodeErr == nil { + model = decoded + } + if decoded, decodeErr := url.QueryUnescape(osVersion); decodeErr == nil { + osVersion = decoded + } + appVersion := strings.TrimSpace(r.Header.Get("X-App-Version")) + if platform == "" { + platform = "unknown" + } + if len(platform) > 20 || len(model) > 100 || len(osVersion) > 50 || len(appVersion) > 30 { + fail(w, http.StatusBadRequest, 20001, "设备信息格式错误") + return + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_devices(user_id,device_id,platform,device_model,os_version,app_version,last_ip,last_active_at,status) + VALUES(?,?,?,?,?,?,?,NOW(3),1) + ON DUPLICATE KEY UPDATE platform=VALUES(platform),device_model=VALUES(device_model),os_version=VALUES(os_version),app_version=VALUES(app_version),last_ip=VALUES(last_ip),last_active_at=NOW(3),status=1`, id, deviceID, platform, model, osVersion, appVersion, clientIP(r)); err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存设备信息失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET last_active_at=NOW(3) WHERE user_id=?`, id); err != nil || tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "保存登录状态失败") + return + } + reply(w, map[string]any{"accessToken": accessToken, "refreshToken": refresh, "expiresIn": 1800, "userId": id}) +} + +func (a *App) refreshToken(w http.ResponseWriter, r *http.Request) { + var req struct { + RefreshToken string `json:"refreshToken"` + } + if err := decode(r, &req); err != nil { + fail(w, 400, 20001, "refreshToken required") + return + } + oldHash := sha256.Sum256([]byte(req.RefreshToken)) + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "刷新登录状态失败") + return + } + defer func() { _ = tx.Rollback() }() + var sessionID, id int64 + var nickname, deviceID string + err = tx.QueryRowContext(r.Context(), `SELECT s.id,s.user_id,p.nickname,s.device_id FROM user_sessions s JOIN users u ON u.id=s.user_id JOIN user_profiles p ON p.user_id=s.user_id WHERE s.refresh_token_hash=? AND s.revoked_at IS NULL AND s.expires_at>NOW(3) AND u.status=1 AND u.deleted_at IS NULL FOR UPDATE`, oldHash[:]).Scan(&sessionID, &id, &nickname, &deviceID) + if err != nil { + fail(w, 401, 10001, "刷新令牌无效") + return + } + accessToken, err := a.token(id, "user", nickname, 30*time.Minute) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建登录令牌失败") + return + } + newRefresh := randomToken() + newHash := sha256.Sum256([]byte(newRefresh)) + result, err := tx.ExecContext(r.Context(), `UPDATE user_sessions SET refresh_token_hash=?,last_active_at=NOW(3) WHERE id=? AND refresh_token_hash=? AND revoked_at IS NULL`, newHash[:], sessionID, oldHash[:]) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "刷新登录状态失败") + return + } + affected, _ := result.RowsAffected() + if affected != 1 || tx.Commit() != nil { + fail(w, http.StatusUnauthorized, 10001, "刷新令牌已被使用") + return + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE user_devices SET last_active_at=NOW(3),last_ip=? WHERE user_id=? AND device_id=?`, clientIP(r), id, deviceID) + reply(w, map[string]any{"accessToken": accessToken, "refreshToken": newRefresh, "expiresIn": 1800}) +} + +func (a *App) logout(w http.ResponseWriter, r *http.Request) { + who := current(r) + var req struct { + RefreshToken string `json:"refreshToken"` + } + if r.Body != nil && r.ContentLength != 0 { + _ = decode(r, &req) + } + if strings.TrimSpace(req.RefreshToken) != "" { + hash := sha256.Sum256([]byte(strings.TrimSpace(req.RefreshToken))) + _, _ = a.db.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND refresh_token_hash=? AND revoked_at IS NULL`, who.ID, hash[:]) + } else { + _, _ = a.db.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, who.ID) + } + _, _ = a.db.ExecContext(r.Context(), `INSERT INTO user_security_controls(user_id,token_version,force_logout_at) VALUES(?,1,NOW(3)) ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=VALUES(force_logout_at)`, who.ID) + a.hub.disconnect(who.ID) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) me(w http.ResponseWriter, r *http.Request) { + profile, err := a.loadProfile(r, current(r).ID, current(r).ID) + if err != nil { + fail(w, 404, 30001, "用户不存在") + return + } + reply(w, profile) +} + +func (a *App) updateProfile(w http.ResponseWriter, r *http.Request) { + var req struct { + Nickname *string `json:"nickname"` + Avatar *string `json:"avatar"` + Cover *string `json:"cover"` + Bio *string `json:"bio"` + City *string `json:"city"` + Birthday *string `json:"birthday"` + Occupation *string `json:"occupation"` + Height *int `json:"height"` + Gender *int `json:"gender"` + Education *int `json:"education"` + RelationshipStatus *int `json:"relationshipStatus"` + TagIDs *[]int64 `json:"tagIds"` + } + if err := decode(r, &req); err != nil { + fail(w, 400, 20001, err.Error()) + return + } + who := current(r) + var nickname, avatar, cover, bio, city, occupation string + var birthday sql.NullString + var height sql.NullInt64 + var gender, education, relationship int + if err := a.db.QueryRowContext(r.Context(), `SELECT nickname,avatar_url,cover_url,bio,city_name,occupation,DATE_FORMAT(birthday,'%Y-%m-%d'),height_cm,gender,education,relationship_status FROM user_profiles WHERE user_id=?`, who.ID).Scan(&nickname, &avatar, &cover, &bio, &city, &occupation, &birthday, &height, &gender, &education, &relationship); err != nil { + fail(w, 500, 50001, "读取资料失败") + return + } + if req.Nickname != nil { + nickname = strings.TrimSpace(*req.Nickname) + } + if req.Avatar != nil { + avatar = strings.TrimSpace(*req.Avatar) + } + if req.Cover != nil { + cover = strings.TrimSpace(*req.Cover) + } + if req.Bio != nil { + bio = strings.TrimSpace(*req.Bio) + } + if req.City != nil { + city = strings.TrimSpace(*req.City) + } + if req.Occupation != nil { + occupation = strings.TrimSpace(*req.Occupation) + } + if req.Height != nil { + if *req.Height < 0 || *req.Height > 260 { + fail(w, 400, 20001, "身高范围无效") + return + } + height = sql.NullInt64{Int64: int64(*req.Height), Valid: *req.Height > 0} + } + if req.Gender != nil { + if *req.Gender < 0 || *req.Gender > 2 { + fail(w, 400, 20001, "性别选项无效") + return + } + gender = *req.Gender + } + if req.Education != nil { + if *req.Education < 0 || *req.Education > 10 { + fail(w, 400, 20001, "学历选项无效") + return + } + education = *req.Education + } + if req.RelationshipStatus != nil { + if *req.RelationshipStatus < 0 || *req.RelationshipStatus > 10 { + fail(w, 400, 20001, "情感状态无效") + return + } + relationship = *req.RelationshipStatus + } + if nickname == "" || len([]rune(nickname)) > 50 || len([]rune(bio)) > 500 || len([]rune(city)) > 50 || len([]rune(occupation)) > 100 { + fail(w, 400, 20001, "资料内容长度无效") + return + } + if req.Birthday != nil { + value := strings.TrimSpace(*req.Birthday) + if value == "" { + birthday = sql.NullString{} + } else { + parsed, parseErr := time.Parse("2006-01-02", value) + if parseErr != nil || parsed.After(time.Now().AddDate(-18, 0, 0)) || parsed.Before(time.Now().AddDate(-100, 0, 0)) { + fail(w, 400, 20001, "仅支持 18-100 周岁的生日日期") + return + } + birthday = sql.NullString{String: value, Valid: true} + } + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + defer func() { _ = tx.Rollback() }() + _, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET nickname=?,avatar_url=?,cover_url=?,bio=?,city_name=?,occupation=?,birthday=?,height_cm=?,gender=?,education=?,relationship_status=?,profile_score=GREATEST(profile_score,80) WHERE user_id=?`, nickname, avatar, cover, bio, city, occupation, birthday, height, gender, education, relationship, who.ID) + if err == nil && req.TagIDs != nil { + if len(*req.TagIDs) > 12 { + fail(w, 400, 20001, "最多选择 12 个标签") + return + } + _, err = tx.ExecContext(r.Context(), `DELETE FROM user_tags WHERE user_id=?`, who.ID) + for _, tagID := range *req.TagIDs { + if err != nil { + break + } + result, insertErr := tx.ExecContext(r.Context(), `INSERT INTO user_tags(user_id,tag_id) SELECT ?,id FROM tags WHERE id=? AND status=1`, who.ID, tagID) + err = insertErr + if err == nil { + affected, _ := result.RowsAffected() + if affected == 0 { + err = fmt.Errorf("标签不存在") + } + } + } + } + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "保存失败") + return + } + a.me(w, r) +} + +func nullableString(v sql.NullString) string { + if v.Valid { + return v.String + } + return "" +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/rate_limit.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/rate_limit.go new file mode 100644 index 0000000..71e57a7 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/rate_limit.go @@ -0,0 +1,56 @@ +package app + +import ( + "context" + "crypto/sha256" + "fmt" + "net" + "net/http" + "strings" + "time" +) + +func (a *App) allowRequest(ctx context.Context, action, subject string, limit int, window time.Duration) bool { + if limit < 1 || window < time.Second { + return false + } + windowSeconds := int64(window / time.Second) + slot := time.Now().Unix() / windowSeconds + key := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d", action, subject, slot))) + expiresAt := time.Unix((slot+1)*windowSeconds, 0).Add(time.Minute) + _, err := a.db.ExecContext(ctx, `INSERT INTO api_rate_limits(bucket_key,action_name,hits,expires_at) VALUES(?,?,1,?) ON DUPLICATE KEY UPDATE hits=hits+1,expires_at=VALUES(expires_at)`, key[:], action, expiresAt) + if err != nil { + return false + } + var hits int + if err = a.db.QueryRowContext(ctx, `SELECT hits FROM api_rate_limits WHERE bucket_key=?`, key[:]).Scan(&hits); err != nil { + return false + } + if key[0] == 0 { + _, _ = a.db.ExecContext(ctx, `DELETE FROM api_rate_limits WHERE expires_at 0 { + return nil + } + + passwordHash, err := hashPassword("123456") + if err != nil { + return err + } + tx, err := a.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + ids := make([]int64, 0, len(demoUsers)) + for index, item := range demoUsers { + birthday := time.Now().AddDate(-item.Age, 0, 0).Format("2006-01-02") + phoneCipher, encryptErr := a.encryptPhone(item.Phone) + if encryptErr != nil { + return encryptErr + } + result, execErr := tx.Exec(`INSERT INTO users (public_id,country_code,phone_hash,phone_cipher,password_hash,status,risk_level) + VALUES (?,?,?,?,?,1,0)`, fmt.Sprintf("XY%08d", index+10001), "+86", phoneHash(item.Phone), phoneCipher, passwordHash) + if execErr != nil { + return execErr + } + id, _ := result.LastInsertId() + ids = append(ids, id) + _, execErr = tx.Exec(`INSERT INTO user_profiles + (user_id,nickname,avatar_url,cover_url,gender,birthday,height_cm,city_code,city_name,occupation,bio,profile_score,is_vip,vip_level,last_active_at) + VALUES (?,?,?,?,?,?,?,?,'上海',?,?,95,?,?,?)`, id, item.Nickname, item.Avatar, item.Cover, item.Gender, birthday, 163+index%12, "310100", "创意行业", item.Bio, btoi(item.VIP > 0), item.VIP, time.Now().Add(-time.Duration(index*4)*time.Minute)) + if execErr != nil { + return execErr + } + _, _ = tx.Exec(`INSERT INTO user_privacy_settings (user_id) VALUES (?)`, id) + _, _ = tx.Exec(`INSERT INTO user_location_states (user_id,city_code,location_cell,latitude,longitude,source) VALUES (?,'310100','wx4g',?,?,'seed')`, id, item.Lat, item.Lng) + _, _ = tx.Exec(`INSERT INTO user_risk_profiles (user_id,risk_score,risk_level) VALUES (?, ?, ?)`, id, index*3, btoi(index == 7)) + } + + mediaSets := [][]string{ + {"https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=900&auto=format&fit=crop", "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=900&auto=format&fit=crop", "https://images.unsplash.com/photo-1470770841072-f978cf4d019e?w=900&auto=format&fit=crop"}, + {"https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?w=900&auto=format&fit=crop", "https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?w=900&auto=format&fit=crop", "https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=900&auto=format&fit=crop"}, + {"https://images.unsplash.com/photo-1470252649378-9c29740c9fa8?w=900&auto=format&fit=crop"}, + } + contents := []string{"今天的天空很美,心情也很好~", "周末去爬山啦", "晚霞也太治愈了吧"} + for index, content := range contents { + result, execErr := tx.Exec(`INSERT INTO posts (user_id,content,city_code,location_text,like_count,comment_count) VALUES (?,?, '310100','上海',?,?)`, ids[index+1], content, 23+index*13, 8+index*2) + if execErr != nil { + return execErr + } + postID, _ := result.LastInsertId() + for order, url := range mediaSets[index] { + _, _ = tx.Exec(`INSERT INTO post_media (post_id,media_url,media_type,sort_order) VALUES (?,?,'image',?)`, postID, url, order) + } + } + + for i := 1; i < len(ids); i++ { + _, _ = tx.Exec(`INSERT INTO user_follows (user_id,target_user_id) VALUES (?,?)`, ids[0], ids[i]) + if i < 5 { + _, _ = tx.Exec(`INSERT INTO user_likes (user_id,target_user_id,source) VALUES (?,?, 'seed')`, ids[0], ids[i]) + } + } + + conversationResult, err := tx.Exec(`INSERT INTO im_conversations (conversation_type,last_seq,last_message_at) VALUES (1,3,NOW(3))`) + if err != nil { + return err + } + conversationID, _ := conversationResult.LastInsertId() + _, _ = tx.Exec(`INSERT INTO im_direct_conversations (conversation_id,user1_id,user2_id) VALUES (?,?,?)`, conversationID, ids[0], ids[1]) + _, _ = tx.Exec(`INSERT INTO im_conversation_members (conversation_id,user_id,read_seq,delivered_seq) VALUES (?,?,3,3),(?,?,1,3)`, conversationID, ids[0], conversationID, ids[1]) + messages := []struct { + sender int64 + text string + }{{ids[1], "今天的晚霞好美呀~"}, {ids[0], "阳光正好,想和你去看一次日落"}, {ids[1], "好呀好呀,我也正想去看呢!"}} + for index, message := range messages { + body, _ := json.Marshal(map[string]string{"text": message.text}) + result, execErr := tx.Exec(`INSERT INTO im_messages (conversation_id,seq,sender_id,client_msg_id,message_type,body) VALUES (?,?,?,?,1,?)`, conversationID, index+1, message.sender, fmt.Sprintf("01JDEMO%019d", index+1), body) + if execErr != nil { + return execErr + } + if index == len(messages)-1 { + messageID, _ := result.LastInsertId() + _, _ = tx.Exec(`UPDATE im_conversations SET last_message_id=? WHERE id=?`, messageID, conversationID) + } + } + + _, _ = tx.Exec(`INSERT INTO notifications (user_id,type,title,content,biz_type,biz_id) VALUES + (?,'follow','新的关注','爱笑的眼睛关注了你','user',?), + (?,'like','新的喜欢','小鹿心喜欢了你','user',?), + (?,'system','欢迎来到星遇','完善资料可以获得更多推荐','',NULL)`, ids[0], ids[2], ids[0], ids[1], ids[0]) + _, _ = tx.Exec(`INSERT INTO reports (reporter_user_id,target_type,target_id,reason_code,description,status) VALUES (?, 'user', ?, 'advertising', '频繁发送广告链接', 'PENDING')`, ids[2], ids[7]) + _, _ = tx.Exec(`INSERT INTO risk_events (user_id,event_type,score_delta,device_id,ip,metadata) VALUES (?, 'rapid_messages', 12, 'demo-device', '127.0.0.1', JSON_OBJECT('count', 32))`, ids[7]) + _, _ = tx.Exec(`INSERT INTO orders (order_no,user_id,product_type,product_id,amount_cent,status,channel,paid_at) VALUES ('XYDEMO202608240001', ?, 'membership', 2, 6800, 'PAID', 'alipay', NOW(3))`, ids[0]) + return tx.Commit() +} + +func btoi(value bool) int { + if value { + return 1 + } + return 0 +} + +func scanNullableString(value sql.NullString) string { + if value.Valid { + return value.String + } + return "" +} + +func sha(value string) []byte { sum := sha256.Sum256([]byte(value)); return sum[:] } diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/sms_providers.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/sms_providers.go new file mode 100644 index 0000000..be4f49a --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/sms_providers.go @@ -0,0 +1,416 @@ +package app + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const smsResponseLimit = 256 << 10 + +type smsDeliveryResult struct { + MessageID string +} + +func parseSMSProviderEndpoint(provider, raw string) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf("%s API 地址必须是无账号、查询参数和片段的有效 HTTPS 地址", cloudSMSProviderName(provider)) + } + path := strings.TrimRight(parsed.EscapedPath(), "/") + switch provider { + case "aliyun", "tencent": + if path != "" { + return nil, fmt.Errorf("%s API 地址不能包含路径", cloudSMSProviderName(provider)) + } + case "huawei": + if !strings.HasSuffix(path, "/sms/batchSendSms/v1") { + return nil, fmt.Errorf("华为云 APP 接入地址必须包含 /sms/batchSendSms/v1") + } + } + return parsed, nil +} + +func (a *App) validateSMSProviderConfig(ctx context.Context) error { + provider := a.configPlain(ctx, "sms.provider", "debug") + if !containsString([]string{"aliyun", "tencent", "huawei", "webhook", "debug"}, provider) { + return fmt.Errorf("不支持的短信厂商 %q", provider) + } + if a.config.Environment == "production" && provider == "debug" { + return fmt.Errorf("生产环境禁止使用 debug 短信提供商") + } + for _, spec := range integrationSpecs["sms"] { + if !spec.Required || (len(spec.Providers) > 0 && !containsString(spec.Providers, provider)) { + continue + } + if strings.TrimSpace(a.configPlain(ctx, spec.Key, "")) == "" { + return fmt.Errorf("请填写%s", spec.Label) + } + } + + switch provider { + case "aliyun", "tencent", "huawei": + endpoint := a.configPlain(ctx, "sms."+provider+".endpoint", "") + if _, err := parseSMSProviderEndpoint(provider, endpoint); err != nil { + return err + } + if _, err := a.smsTemplateParams(ctx, provider, "000000"); err != nil { + return err + } + case "webhook": + endpoint := a.configPlain(ctx, "sms.webhook_url", "") + if a.config.Environment == "production" && !validHTTPSURL(endpoint) { + return fmt.Errorf("生产环境 Webhook 地址必须使用 HTTPS") + } + } + return nil +} + +func cloudSMSProviderName(provider string) string { + switch provider { + case "aliyun": + return "阿里云" + case "tencent": + return "腾讯云" + case "huawei": + return "华为云" + case "webhook": + return "Webhook" + case "debug": + return "本地调试" + default: + return provider + } +} + +func (a *App) smsTemplateID(ctx context.Context, provider, scene string) (string, error) { + if !containsString([]string{"register", "login", "reset"}, scene) { + return "", fmt.Errorf("短信验证码场景无效") + } + key := "sms." + provider + ".template_" + scene + if provider == "webhook" { + key = "sms.template_" + scene + } + value := strings.TrimSpace(a.configPlain(ctx, key, "")) + if value == "" { + return "", fmt.Errorf("%s%s模板未配置", cloudSMSProviderName(provider), scene) + } + return value, nil +} + +func (a *App) smsTemplateParams(ctx context.Context, provider, code string) (string, error) { + raw := a.configPlain(ctx, "sms."+provider+".template_params", "") + expires, _ := strconv.Atoi(a.configPlain(ctx, "sms.expire_seconds", "300")) + return renderSMSTemplateParams(provider, raw, code, expires) +} + +func renderSMSTemplateParams(provider, raw, code string, expires int) (string, error) { + if expires < 60 || expires > 1800 { + expires = 300 + } + minutes := (expires + 59) / 60 + rendered := strings.ReplaceAll(raw, "{{code}}", code) + rendered = strings.ReplaceAll(rendered, "{{minutes}}", strconv.Itoa(minutes)) + if provider == "aliyun" { + var object map[string]any + if json.Unmarshal([]byte(rendered), &object) != nil || len(object) == 0 { + return "", fmt.Errorf("阿里云模板变量必须是有效的非空 JSON 对象") + } + payload, _ := json.Marshal(object) + return string(payload), nil + } + var values []string + if json.Unmarshal([]byte(rendered), &values) != nil || len(values) == 0 { + return "", fmt.Errorf("%s模板参数必须是有效的非空 JSON 字符串数组", cloudSMSProviderName(provider)) + } + payload, _ := json.Marshal(values) + return string(payload), nil +} + +func huaweiWSSE(appKey, appSecret, nonce, created string) (string, string) { + digestHash := sha256.Sum256([]byte(nonce + created + appSecret)) + passwordDigest := base64.StdEncoding.EncodeToString(digestHash[:]) + authorization := `WSSE realm="SDP",profile="UsernameToken",type="Appkey"` + wsse := `UsernameToken Username="` + appKey + `",PasswordDigest="` + passwordDigest + `",Nonce="` + nonce + `",Created="` + created + `"` + return authorization, wsse +} + +func smsHTTPClient() *http.Client { + return &http.Client{Timeout: 8 * time.Second} +} + +func readSMSResponse(response *http.Response) ([]byte, error) { + body, err := io.ReadAll(io.LimitReader(response.Body, smsResponseLimit)) + if err != nil { + return nil, err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("短信厂商返回 HTTP %d", response.StatusCode) + } + return body, nil +} + +func providerError(provider, code, message string) error { + message = strings.TrimSpace(message) + if len(message) > 300 { + message = message[:300] + } + if message == "" { + message = "请求失败" + } + return fmt.Errorf("%s短信发送失败 [%s]: %s", cloudSMSProviderName(provider), code, message) +} + +func (a *App) sendAliyunSMS(ctx context.Context, phone, scene, code string) (smsDeliveryResult, error) { + endpoint := a.configPlain(ctx, "sms.aliyun.endpoint", "https://dysmsapi.aliyuncs.com") + parsed, err := parseSMSProviderEndpoint("aliyun", endpoint) + if err != nil { + return smsDeliveryResult{}, err + } + templateID, err := a.smsTemplateID(ctx, "aliyun", scene) + if err != nil { + return smsDeliveryResult{}, err + } + templateParams, err := a.smsTemplateParams(ctx, "aliyun", code) + if err != nil { + return smsDeliveryResult{}, err + } + query := url.Values{ + "PhoneNumbers": {phone}, + "SignName": {a.configPlain(ctx, "sms.aliyun.sign_name", "")}, + "TemplateCode": {templateID}, + "TemplateParam": {templateParams}, + } + canonicalQuery := strings.ReplaceAll(query.Encode(), "+", "%20") + parsed.RawQuery = canonicalQuery + canonicalURI := "/" + now := time.Now().UTC().Format("2006-01-02T15:04:05Z") + nonce := randomToken()[:32] + emptyHash := sha256.Sum256(nil) + payloadHash := hex.EncodeToString(emptyHash[:]) + canonicalHeaders := "host:" + parsed.Host + "\n" + + "x-acs-action:SendSms\n" + + "x-acs-content-sha256:" + payloadHash + "\n" + + "x-acs-date:" + now + "\n" + + "x-acs-signature-nonce:" + nonce + "\n" + + "x-acs-version:2017-05-25\n" + signedHeaders := "host;x-acs-action;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version" + canonicalRequest := "POST\n" + canonicalURI + "\n" + canonicalQuery + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + payloadHash + requestHash := sha256.Sum256([]byte(canonicalRequest)) + stringToSign := "ACS3-HMAC-SHA256\n" + hex.EncodeToString(requestHash[:]) + accessKeyID := a.configPlain(ctx, "sms.aliyun.access_key_id", "") + mac := hmac.New(sha256.New, []byte(a.configPlain(ctx, "sms.aliyun.access_key_secret", ""))) + _, _ = mac.Write([]byte(stringToSign)) + authorization := "ACS3-HMAC-SHA256 Credential=" + accessKeyID + ",SignedHeaders=" + signedHeaders + ",Signature=" + hex.EncodeToString(mac.Sum(nil)) + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), nil) + if err != nil { + return smsDeliveryResult{}, err + } + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", authorization) + request.Header.Set("x-acs-action", "SendSms") + request.Header.Set("x-acs-content-sha256", payloadHash) + request.Header.Set("x-acs-date", now) + request.Header.Set("x-acs-signature-nonce", nonce) + request.Header.Set("x-acs-version", "2017-05-25") + response, err := smsHTTPClient().Do(request) + if err != nil { + return smsDeliveryResult{}, fmt.Errorf("阿里云短信连接失败: %w", err) + } + defer response.Body.Close() + body, err := readSMSResponse(response) + if err != nil { + return smsDeliveryResult{}, err + } + var result struct { + BizID string `json:"BizId"` + Code string `json:"Code"` + Message string `json:"Message"` + RequestID string `json:"RequestId"` + } + if json.Unmarshal(body, &result) != nil { + return smsDeliveryResult{}, fmt.Errorf("阿里云短信返回无效 JSON") + } + if result.Code != "OK" { + return smsDeliveryResult{}, providerError("aliyun", result.Code, result.Message) + } + return smsDeliveryResult{MessageID: result.BizID}, nil +} + +func hmacSHA256(key, value []byte) []byte { + mac := hmac.New(sha256.New, key) + _, _ = mac.Write(value) + return mac.Sum(nil) +} + +func (a *App) sendTencentSMS(ctx context.Context, phone, scene, code string) (smsDeliveryResult, error) { + endpoint := a.configPlain(ctx, "sms.tencent.endpoint", "https://sms.tencentcloudapi.com") + parsed, err := parseSMSProviderEndpoint("tencent", endpoint) + if err != nil { + return smsDeliveryResult{}, err + } + templateID, err := a.smsTemplateID(ctx, "tencent", scene) + if err != nil { + return smsDeliveryResult{}, err + } + paramsJSON, err := a.smsTemplateParams(ctx, "tencent", code) + if err != nil { + return smsDeliveryResult{}, err + } + var params []string + _ = json.Unmarshal([]byte(paramsJSON), ¶ms) + payload, _ := json.Marshal(map[string]any{ + "PhoneNumberSet": []string{"+86" + phone}, + "SignName": a.configPlain(ctx, "sms.tencent.sign_name", ""), + "SmsSdkAppId": a.configPlain(ctx, "sms.tencent.sdk_app_id", ""), + "TemplateId": templateID, + "TemplateParamSet": params, + }) + timestamp := time.Now().Unix() + date := time.Unix(timestamp, 0).UTC().Format("2006-01-02") + contentType := "application/json; charset=utf-8" + canonicalHeaders := "content-type:" + contentType + "\nhost:" + parsed.Host + "\n" + signedHeaders := "content-type;host" + payloadHash := sha256.Sum256(payload) + canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hex.EncodeToString(payloadHash[:]) + canonicalHash := sha256.Sum256([]byte(canonicalRequest)) + credentialScope := date + "/sms/tc3_request" + stringToSign := "TC3-HMAC-SHA256\n" + strconv.FormatInt(timestamp, 10) + "\n" + credentialScope + "\n" + hex.EncodeToString(canonicalHash[:]) + secretKey := a.configPlain(ctx, "sms.tencent.secret_key", "") + secretDate := hmacSHA256([]byte("TC3"+secretKey), []byte(date)) + secretService := hmacSHA256(secretDate, []byte("sms")) + secretSigning := hmacSHA256(secretService, []byte("tc3_request")) + signature := hex.EncodeToString(hmacSHA256(secretSigning, []byte(stringToSign))) + authorization := "TC3-HMAC-SHA256 Credential=" + a.configPlain(ctx, "sms.tencent.secret_id", "") + "/" + credentialScope + ", SignedHeaders=" + signedHeaders + ", Signature=" + signature + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload)) + if err != nil { + return smsDeliveryResult{}, err + } + request.Header.Set("Authorization", authorization) + request.Header.Set("Content-Type", contentType) + request.Header.Set("X-TC-Action", "SendSms") + request.Header.Set("X-TC-Version", "2021-01-11") + request.Header.Set("X-TC-Timestamp", strconv.FormatInt(timestamp, 10)) + request.Header.Set("X-TC-Region", a.configPlain(ctx, "sms.tencent.region", "ap-guangzhou")) + response, err := smsHTTPClient().Do(request) + if err != nil { + return smsDeliveryResult{}, fmt.Errorf("腾讯云短信连接失败: %w", err) + } + defer response.Body.Close() + body, err := readSMSResponse(response) + if err != nil { + return smsDeliveryResult{}, err + } + var result struct { + Response struct { + Error *struct { + Code string `json:"Code"` + Message string `json:"Message"` + } `json:"Error"` + RequestID string `json:"RequestId"` + SendStatusSet []struct { + Code string `json:"Code"` + Message string `json:"Message"` + SerialNo string `json:"SerialNo"` + } `json:"SendStatusSet"` + } `json:"Response"` + } + if json.Unmarshal(body, &result) != nil { + return smsDeliveryResult{}, fmt.Errorf("腾讯云短信返回无效 JSON") + } + if result.Response.Error != nil { + return smsDeliveryResult{}, providerError("tencent", result.Response.Error.Code, result.Response.Error.Message) + } + if len(result.Response.SendStatusSet) == 0 { + return smsDeliveryResult{}, fmt.Errorf("腾讯云短信未返回发送状态") + } + status := result.Response.SendStatusSet[0] + if !strings.EqualFold(status.Code, "Ok") { + return smsDeliveryResult{}, providerError("tencent", status.Code, status.Message) + } + return smsDeliveryResult{MessageID: status.SerialNo}, nil +} + +func (a *App) sendHuaweiSMS(ctx context.Context, phone, scene, code string) (smsDeliveryResult, error) { + endpoint := a.configPlain(ctx, "sms.huawei.endpoint", "") + parsed, err := parseSMSProviderEndpoint("huawei", endpoint) + if err != nil { + return smsDeliveryResult{}, err + } + templateID, err := a.smsTemplateID(ctx, "huawei", scene) + if err != nil { + return smsDeliveryResult{}, err + } + paramsJSON, err := a.smsTemplateParams(ctx, "huawei", code) + if err != nil { + return smsDeliveryResult{}, err + } + appKey := a.configPlain(ctx, "sms.huawei.app_key", "") + nonce := randomToken()[:32] + created := time.Now().UTC().Format("2006-01-02T15:04:05Z") + authorization, wsse := huaweiWSSE(appKey, a.configPlain(ctx, "sms.huawei.app_secret", ""), nonce, created) + form := url.Values{ + "from": {a.configPlain(ctx, "sms.huawei.sender", "")}, + "to": {"+86" + phone}, + "templateId": {templateID}, + "templateParas": {paramsJSON}, + } + if signature := strings.TrimSpace(a.configPlain(ctx, "sms.huawei.signature", "")); signature != "" { + form.Set("signature", signature) + } + if callback := strings.TrimSpace(a.configPlain(ctx, "sms.huawei.status_callback", "")); callback != "" { + form.Set("statusCallback", callback) + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), strings.NewReader(form.Encode())) + if err != nil { + return smsDeliveryResult{}, err + } + request.Header.Set("Accept", "application/json") + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Authorization", authorization) + request.Header.Set("X-WSSE", wsse) + response, err := smsHTTPClient().Do(request) + if err != nil { + return smsDeliveryResult{}, fmt.Errorf("华为云短信连接失败: %w", err) + } + defer response.Body.Close() + body, err := readSMSResponse(response) + if err != nil { + return smsDeliveryResult{}, err + } + var result struct { + Code string `json:"code"` + Description string `json:"description"` + Result []struct { + MessageID string `json:"smsMsgId"` + Status string `json:"status"` + } `json:"result"` + } + if json.Unmarshal(body, &result) != nil { + return smsDeliveryResult{}, fmt.Errorf("华为云短信返回无效 JSON") + } + if result.Code != "000000" { + return smsDeliveryResult{}, providerError("huawei", result.Code, result.Description) + } + if len(result.Result) == 0 || result.Result[0].Status != "000000" { + status := "EMPTY_RESULT" + if len(result.Result) > 0 { + status = result.Result[0].Status + } + return smsDeliveryResult{}, providerError("huawei", status, "短信未被平台接受") + } + return smsDeliveryResult{MessageID: result.Result[0].MessageID}, nil +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/sms_providers_test.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/sms_providers_test.go new file mode 100644 index 0000000..9971b59 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/sms_providers_test.go @@ -0,0 +1,52 @@ +package app + +import ( + "strings" + "testing" +) + +func TestRenderSMSTemplateParams(t *testing.T) { + aliyun, err := renderSMSTemplateParams("aliyun", `{"code":"{{code}}","minutes":"{{minutes}}"}`, "086421", 301) + if err != nil || aliyun != `{"code":"086421","minutes":"6"}` { + t.Fatalf("unexpected aliyun template parameters: %q, %v", aliyun, err) + } + tencent, err := renderSMSTemplateParams("tencent", `["{{code}}","{{minutes}}"]`, "086421", 300) + if err != nil || tencent != `["086421","5"]` { + t.Fatalf("unexpected tencent template parameters: %q, %v", tencent, err) + } + if _, err = renderSMSTemplateParams("huawei", `{"code":"{{code}}"}`, "086421", 300); err == nil { + t.Fatal("huawei parameters must reject a non-array JSON value") + } +} + +func TestHuaweiWSSE(t *testing.T) { + authorization, wsse := huaweiWSSE("app-key", "secret", "abc", "2026-08-25T10:00:00Z") + if authorization != `WSSE realm="SDP",profile="UsernameToken",type="Appkey"` { + t.Fatalf("unexpected authorization header: %s", authorization) + } + if !strings.Contains(wsse, `Username="app-key"`) || !strings.Contains(wsse, `PasswordDigest="imMh4+lxH6z6wYNFDE3+ycvpRGbC7S/R2Q7ehhnXEqU="`) { + t.Fatalf("unexpected X-WSSE header: %s", wsse) + } +} + +func TestParseSMSProviderEndpoint(t *testing.T) { + tests := []struct { + provider string + endpoint string + valid bool + }{ + {provider: "aliyun", endpoint: "https://dysmsapi.aliyuncs.com", valid: true}, + {provider: "tencent", endpoint: "https://sms.tencentcloudapi.com/", valid: true}, + {provider: "huawei", endpoint: "https://smsapi.cn-north-4.myhuaweicloud.com:443/sms/batchSendSms/v1", valid: true}, + {provider: "aliyun", endpoint: "http://dysmsapi.aliyuncs.com", valid: false}, + {provider: "tencent", endpoint: "https://sms.tencentcloudapi.com/custom", valid: false}, + {provider: "huawei", endpoint: "https://user:pass@example.com/sms/batchSendSms/v1", valid: false}, + {provider: "huawei", endpoint: "https://example.com/sms/batchSendSms/v1?token=secret", valid: false}, + } + for _, test := range tests { + _, err := parseSMSProviderEndpoint(test.provider, test.endpoint) + if (err == nil) != test.valid { + t.Errorf("parseSMSProviderEndpoint(%q, %q) error = %v, valid = %v", test.provider, test.endpoint, err, test.valid) + } + } +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/social.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/social.go new file mode 100644 index 0000000..7e4761f --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/social.go @@ -0,0 +1,658 @@ +package app + +import ( + "database/sql" + "encoding/json" + "fmt" + "math" + "net/http" + "strconv" + "strings" + "time" +) + +type profileView struct { + ID int64 `json:"id"` + PublicID string `json:"publicId"` + IsTest bool `json:"isTest"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Cover string `json:"cover"` + Gender int `json:"gender"` + Age int `json:"age"` + Height int `json:"height"` + City string `json:"city"` + Birthday string `json:"birthday"` + Occupation string `json:"occupation"` + Education int `json:"education"` + RelationshipStatus int `json:"relationshipStatus"` + Bio string `json:"bio"` + VIP bool `json:"vip"` + VIPLevel int `json:"vipLevel"` + Online bool `json:"online"` + LastActiveAt *time.Time `json:"lastActiveAt,omitempty"` + Distance float64 `json:"distance"` + DistanceText string `json:"distanceText"` + FollowingCount int `json:"followingCount"` + FollowerCount int `json:"followerCount"` + PostCount int `json:"postCount"` + LikeCount int `json:"likeCount"` + Following bool `json:"following"` + Liked bool `json:"liked"` + Tags []string `json:"tags"` +} + +func (a *App) loadProfile(r *http.Request, id, viewerID int64) (profileView, error) { + var item profileView + var birthday sql.NullTime + var active sql.NullTime + var vip int + var onlineVisible, lastActiveVisible int + err := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.public_id,u.is_test,p.nickname,p.avatar_url,p.cover_url,p.gender,p.birthday,COALESCE(p.height_cm,0),p.city_name,p.occupation,p.education,p.relationship_status,p.bio,p.is_vip,p.vip_level,p.last_active_at,privacy.online_visible,privacy.last_active_visible, + (SELECT COUNT(*) FROM user_follows WHERE user_id=u.id),(SELECT COUNT(*) FROM user_follows WHERE target_user_id=u.id),(SELECT COUNT(*) FROM posts WHERE user_id=u.id AND status=1),(SELECT COUNT(*) FROM post_likes pl JOIN posts po ON po.id=pl.post_id WHERE po.user_id=u.id), + EXISTS(SELECT 1 FROM user_follows WHERE user_id=? AND target_user_id=u.id),EXISTS(SELECT 1 FROM user_likes WHERE user_id=? AND target_user_id=u.id) + FROM users u JOIN user_profiles p ON p.user_id=u.id JOIN user_privacy_settings privacy ON privacy.user_id=u.id + WHERE u.id=? AND u.status=1 AND (u.id=? OR NOT EXISTS(SELECT 1 FROM user_blocks blocked WHERE (blocked.user_id=? AND blocked.blocked_user_id=u.id) OR (blocked.user_id=u.id AND blocked.blocked_user_id=?)))`, viewerID, viewerID, id, viewerID, viewerID, viewerID).Scan( + &item.ID, &item.PublicID, &item.IsTest, &item.Nickname, &item.Avatar, &item.Cover, &item.Gender, &birthday, &item.Height, &item.City, &item.Occupation, &item.Education, &item.RelationshipStatus, &item.Bio, &vip, &item.VIPLevel, &active, &onlineVisible, &lastActiveVisible, &item.FollowingCount, &item.FollowerCount, &item.PostCount, &item.LikeCount, &item.Following, &item.Liked) + if err != nil { + return item, err + } + item.VIP = vip == 1 + if birthday.Valid { + item.Age = age(birthday.Time) + item.Birthday = birthday.Time.Format("2006-01-02") + } + item.Online = (id == viewerID || onlineVisible == 1) && active.Valid && time.Since(active.Time) < 15*time.Minute + if active.Valid && (id == viewerID || lastActiveVisible == 1) { + activeTime := active.Time + item.LastActiveAt = &activeTime + } + item.Tags = []string{} + tagRows, tagErr := a.db.QueryContext(r.Context(), `SELECT t.name FROM user_tags ut JOIN tags t ON t.id=ut.tag_id WHERE ut.user_id=? AND t.status=1 ORDER BY t.sort_order,t.id LIMIT 12`, id) + if tagErr == nil { + defer tagRows.Close() + for tagRows.Next() { + var name string + if tagRows.Scan(&name) == nil { + item.Tags = append(item.Tags, name) + } + } + } + return item, nil +} + +func (a *App) userProfile(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "invalid id") + return + } + who := current(r) + if id != who.ID { + var targetAllows, viewerInvisible int + _ = a.db.QueryRowContext(r.Context(), `SELECT allow_profile_visit_record FROM user_privacy_settings WHERE user_id=?`, id).Scan(&targetAllows) + _ = a.db.QueryRowContext(r.Context(), `SELECT invisible_visit FROM user_privacy_settings WHERE user_id=?`, who.ID).Scan(&viewerInvisible) + canHide := viewerInvisible == 1 && a.resolveMembershipEntitlements(r.Context(), who.ID).CanInvisibleVisit + if targetAllows == 1 && !canHide { + _, _ = a.db.ExecContext(r.Context(), `INSERT INTO profile_visits (viewer_user_id,target_user_id,source) VALUES (?,?,'profile')`, who.ID, id) + } + } + item, err := a.loadProfile(r, id, who.ID) + if err != nil { + fail(w, 404, 30001, "用户不存在") + return + } + reply(w, item) +} + +func (a *App) discover(w http.ResponseWriter, r *http.Request) { a.discoverList(w, r, false) } +func (a *App) nearby(w http.ResponseWriter, r *http.Request) { a.discoverList(w, r, true) } + +func (a *App) discoverList(w http.ResponseWriter, r *http.Request, byDistance bool) { + who := current(r) + gender, _ := strconv.Atoi(r.URL.Query().Get("gender")) + scope := r.URL.Query().Get("scope") + sortMode := r.URL.Query().Get("sort") + page, pageSize, offset := pageOptions(r) + var myLat, myLng sql.NullFloat64 + _ = a.db.QueryRowContext(r.Context(), `SELECT latitude,longitude FROM user_location_states WHERE user_id=?`, who.ID).Scan(&myLat, &myLng) + fromWhere := ` FROM users u JOIN user_profiles p ON p.user_id=u.id JOIN user_privacy_settings privacy ON privacy.user_id=u.id LEFT JOIN user_location_states l ON l.user_id=u.id + WHERE u.status=1 AND u.id<>? AND NOT EXISTS(SELECT 1 FROM user_blocks b WHERE (b.user_id=? AND b.blocked_user_id=u.id) OR (b.user_id=u.id AND b.blocked_user_id=?))` + filterArgs := []any{who.ID, who.ID, who.ID} + if scope == "following" { + fromWhere += ` AND EXISTS(SELECT 1 FROM user_follows mine WHERE mine.user_id=? AND mine.target_user_id=u.id)` + filterArgs = append(filterArgs, who.ID) + } + if gender > 0 { + fromWhere += ` AND p.gender=?` + filterArgs = append(filterArgs, gender) + } + if byDistance { + fromWhere += ` AND privacy.nearby_visible=1` + } + var total int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*)`+fromWhere, filterArgs...).Scan(&total); err != nil { + fail(w, 500, 50001, "查询推荐用户失败") + return + } + query := `SELECT u.id,u.public_id,u.is_test,p.nickname,p.avatar_url,p.cover_url,p.gender,p.birthday,COALESCE(p.height_cm,0),p.city_name,p.bio,p.is_vip,p.vip_level,p.last_active_at,l.latitude,l.longitude,privacy.distance_visible,privacy.online_visible,privacy.last_active_visible, + EXISTS(SELECT 1 FROM user_follows f WHERE f.user_id=? AND f.target_user_id=u.id),EXISTS(SELECT 1 FROM user_likes x WHERE x.user_id=? AND x.target_user_id=u.id) + ` + fromWhere + args := append([]any{who.ID, who.ID}, filterArgs...) + if byDistance && myLat.Valid && myLng.Valid { + // Sort before LIMIT/OFFSET so page two can never contain users closer + // than page one. Hidden or missing distances are placed last. + query += ` ORDER BY CASE WHEN privacy.distance_visible=1 THEN COALESCE(ST_Distance_Sphere(POINT(l.longitude,l.latitude),POINT(?,?)),1000000000000000) ELSE 1000000000000000 END ASC,p.last_active_at DESC,u.id DESC LIMIT ? OFFSET ?` + args = append(args, myLng.Float64, myLat.Float64) + } else if sortMode == "latest" || byDistance { + query += ` ORDER BY p.last_active_at DESC,u.id DESC LIMIT ? OFFSET ?` + } else { + query += ` ORDER BY COALESCE((SELECT MAX(plan.recommendation_weight) FROM subscriptions sub JOIN membership_plans plan ON plan.id=sub.plan_id WHERE sub.user_id=u.id AND sub.status=1 AND sub.started_at<=NOW(3) AND sub.expires_at>NOW(3)),0) DESC,p.last_active_at DESC LIMIT ? OFFSET ?` + } + args = append(args, pageSize, offset) + rows, err := a.db.QueryContext(r.Context(), query, args...) + if err != nil { + fail(w, 500, 50001, err.Error()) + return + } + defer rows.Close() + items := []profileView{} + for rows.Next() { + var item profileView + var birthday sql.NullTime + var active sql.NullTime + var lat, lng sql.NullFloat64 + var distanceVisible, onlineVisible, lastActiveVisible int + var vip int + if err := rows.Scan(&item.ID, &item.PublicID, &item.IsTest, &item.Nickname, &item.Avatar, &item.Cover, &item.Gender, &birthday, &item.Height, &item.City, &item.Bio, &vip, &item.VIPLevel, &active, &lat, &lng, &distanceVisible, &onlineVisible, &lastActiveVisible, &item.Following, &item.Liked); err != nil { + continue + } + item.VIP = vip == 1 + if birthday.Valid { + item.Age = age(birthday.Time) + } + item.Online = onlineVisible == 1 && active.Valid && time.Since(active.Time) < 15*time.Minute + if active.Valid && lastActiveVisible == 1 { + activeTime := active.Time + item.LastActiveAt = &activeTime + } + if distanceVisible == 1 && myLat.Valid && myLng.Valid && lat.Valid && lng.Valid { + item.Distance = haversine(myLat.Float64, myLng.Float64, lat.Float64, lng.Float64) + item.DistanceText = distanceText(item.Distance) + } + item.Tags = []string{} + items = append(items, item) + } + reply(w, map[string]any{"items": items, "total": total, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) +} + +func (a *App) updateLocation(w http.ResponseWriter, r *http.Request) { + var req struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + CityCode string `json:"cityCode"` + } + if err := decode(r, &req); err != nil || req.Latitude < -90 || req.Latitude > 90 || req.Longitude < -180 || req.Longitude > 180 { + fail(w, 400, 20001, "无效的位置") + return + } + if req.CityCode == "" { + req.CityCode = "310100" + } + _, err := a.db.ExecContext(r.Context(), `INSERT INTO user_location_states (user_id,city_code,location_cell,latitude,longitude,source) VALUES (?,?, 'wx4g',?,?,'gps') ON DUPLICATE KEY UPDATE city_code=VALUES(city_code),latitude=VALUES(latitude),longitude=VALUES(longitude),last_location_at=NOW(3),source='gps'`, current(r).ID, req.CityCode, req.Latitude, req.Longitude) + if err != nil { + fail(w, 500, 50001, "位置更新失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) follow(w http.ResponseWriter, r *http.Request) { + a.relationship(w, r, "user_follows", true) +} +func (a *App) unfollow(w http.ResponseWriter, r *http.Request) { + a.relationship(w, r, "user_follows", false) +} +func (a *App) likeUser(w http.ResponseWriter, r *http.Request) { + a.relationship(w, r, "user_likes", true) +} +func (a *App) unlikeUser(w http.ResponseWriter, r *http.Request) { + a.relationship(w, r, "user_likes", false) +} + +func (a *App) relationship(w http.ResponseWriter, r *http.Request, table string, create bool) { + target, err := pathID(r) + if err != nil || target == current(r).ID { + fail(w, 400, 20001, "无效用户") + return + } + if create { + var targetExists int + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM users u WHERE u.id=? AND u.status=1 AND u.deleted_at IS NULL AND NOT EXISTS(SELECT 1 FROM user_blocks b WHERE (b.user_id=? AND b.blocked_user_id=u.id) OR (b.user_id=u.id AND b.blocked_user_id=?)))`, target, current(r).ID, current(r).ID).Scan(&targetExists) + if targetExists != 1 { + fail(w, http.StatusNotFound, 30001, "用户不存在或不可操作") + return + } + if table == "user_likes" { + tx, beginErr := a.db.BeginTx(r.Context(), nil) + if beginErr != nil { + fail(w, 500, 50001, "操作失败") + return + } + defer func() { _ = tx.Rollback() }() + var exists int + _ = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM user_likes WHERE user_id=? AND target_user_id=?)`, current(r).ID, target).Scan(&exists) + if exists == 0 { + err = a.reserveDailyLike(tx, r, "user", target) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT IGNORE INTO user_likes (user_id,target_user_id,source) VALUES (?,?,'profile')`, current(r).ID, target) + } + if err == nil { + err = tx.Commit() + } + if limitErr, ok := err.(*dailyLikeLimitError); ok { + fail(w, http.StatusTooManyRequests, 20002, limitErr.Error()) + return + } + } else { + _, err = a.db.ExecContext(r.Context(), `INSERT IGNORE INTO user_follows (user_id,target_user_id) VALUES (?,?)`, current(r).ID, target) + } + } else { + _, err = a.db.ExecContext(r.Context(), `DELETE FROM `+table+` WHERE user_id=? AND target_user_id=?`, current(r).ID, target) + } + if err != nil { + fail(w, 500, 50001, "操作失败") + return + } + if create { + typ, title, content := "follow", "新的关注", current(r).Name+" 关注了你" + if table == "user_likes" { + typ, title, content = "like", "新的喜欢", current(r).Name+" 喜欢了你" + } + a.notifyUser(r.Context(), target, typ, title, content, "user", current(r).ID) + } + reply(w, map[string]bool{"success": true}) +} + +type postView struct { + ID int64 `json:"id"` + User profileView `json:"user"` + Content string `json:"content"` + Location string `json:"location"` + LikeCount int `json:"likeCount"` + CommentCount int `json:"commentCount"` + Liked bool `json:"liked"` + Media []string `json:"media"` + CreatedAt time.Time `json:"createdAt"` + CanEdit bool `json:"canEdit"` + CanDelete bool `json:"canDelete"` +} + +func (a *App) feed(w http.ResponseWriter, r *http.Request) { + who := current(r) + page, pageSize, offset := pageOptions(r) + scopeFollowing := r.URL.Query().Get("scope") == "following" + countQuery := `SELECT COUNT(*) FROM posts p JOIN users u ON u.id=p.user_id JOIN user_profiles pr ON pr.user_id=p.user_id WHERE p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id)))` + countArgs := []any{who.ID, who.ID} + if scopeFollowing { + countQuery += ` AND EXISTS(SELECT 1 FROM user_follows f WHERE f.user_id=? AND f.target_user_id=p.user_id)` + countArgs = append(countArgs, who.ID) + } + var total int + if err := a.db.QueryRowContext(r.Context(), countQuery, countArgs...).Scan(&total); err != nil { + fail(w, 500, 50001, "查询动态失败") + return + } + query := `SELECT p.id,p.user_id,p.content,p.location_text,p.like_count,p.comment_count,p.created_at,u.public_id,pr.nickname,pr.avatar_url,pr.gender,pr.is_vip,EXISTS(SELECT 1 FROM post_likes l WHERE l.post_id=p.id AND l.user_id=?) FROM posts p JOIN users u ON u.id=p.user_id JOIN user_profiles pr ON pr.user_id=p.user_id WHERE p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id)))` + args := []any{who.ID, who.ID, who.ID} + if scopeFollowing { + query += ` AND EXISTS(SELECT 1 FROM user_follows f WHERE f.user_id=? AND f.target_user_id=p.user_id)` + args = append(args, who.ID) + } + query += ` ORDER BY p.created_at DESC LIMIT ? OFFSET ?` + args = append(args, pageSize, offset) + rows, err := a.db.QueryContext(r.Context(), query, args...) + if err != nil { + fail(w, 500, 50001, err.Error()) + return + } + defer rows.Close() + items := []postView{} + for rows.Next() { + var item postView + var uid int64 + var vip int + if rows.Scan(&item.ID, &uid, &item.Content, &item.Location, &item.LikeCount, &item.CommentCount, &item.CreatedAt, &item.User.PublicID, &item.User.Nickname, &item.User.Avatar, &item.User.Gender, &vip, &item.Liked) != nil { + continue + } + item.User.ID = uid + item.User.VIP = vip == 1 + item.CanEdit = uid == who.ID + item.CanDelete = uid == who.ID + mediaRows, _ := a.db.QueryContext(r.Context(), `SELECT media_url FROM post_media WHERE post_id=? ORDER BY sort_order`, item.ID) + item.Media = []string{} + if mediaRows != nil { + for mediaRows.Next() { + var url string + _ = mediaRows.Scan(&url) + item.Media = append(item.Media, url) + } + _ = mediaRows.Close() + } + items = append(items, item) + } + reply(w, map[string]any{"items": items, "total": total, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) +} + +func (a *App) createPost(w http.ResponseWriter, r *http.Request) { + if a.isSanctionActive(r.Context(), current(r).ID, "CONTENT_LIMIT") { + fail(w, http.StatusForbidden, 10006, "账号处于内容发布限制期") + return + } + var req struct { + Content string `json:"content"` + Media []string `json:"media"` + Location string `json:"location"` + Visibility int `json:"visibility"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "动态格式错误") + return + } + req.Content = strings.TrimSpace(req.Content) + req.Location = strings.TrimSpace(req.Location) + if (req.Content == "" && len(req.Media) == 0) || len([]rune(req.Content)) > 2000 || len(req.Media) > 9 || len([]rune(req.Location)) > 100 { + fail(w, 400, 20001, "动态需包含文字或图片,最多 9 张图片") + return + } + if req.Visibility != 2 { + req.Visibility = 1 + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "发布失败") + return + } + defer func() { _ = tx.Rollback() }() + for _, rawURL := range req.Media { + mediaURL := strings.TrimSpace(rawURL) + var exists int + if mediaURL == "" || tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM media_assets WHERE owner_user_id=? AND public_url=? AND status=1 AND moderation_status=1)`, current(r).ID, mediaURL).Scan(&exists) != nil || exists != 1 { + fail(w, 400, 20001, "动态图片必须由当前账号上传且通过安全检查") + return + } + } + result, err := tx.ExecContext(r.Context(), `INSERT INTO posts (user_id,content,visibility,city_code,location_text) VALUES (?,?,?,'310100',?)`, current(r).ID, req.Content, req.Visibility, req.Location) + if err != nil { + _ = tx.Rollback() + fail(w, 500, 50001, "发布失败") + return + } + id, _ := result.LastInsertId() + for i, url := range req.Media { + if _, err = tx.ExecContext(r.Context(), `INSERT INTO post_media(post_id,media_url,sort_order)VALUES(?,?,?)`, id, url, i); err != nil { + fail(w, 500, 50001, "发布失败") + return + } + } + if tx.Commit() != nil { + fail(w, 500, 50001, "发布失败") + return + } + reply(w, map[string]any{"id": id}) +} + +func (a *App) likePost(w http.ResponseWriter, r *http.Request) { a.postLike(w, r, true) } +func (a *App) unlikePost(w http.ResponseWriter, r *http.Request) { a.postLike(w, r, false) } +func (a *App) postLike(w http.ResponseWriter, r *http.Request, create bool) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "invalid id") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "点赞失败") + return + } + defer func() { _ = tx.Rollback() }() + var postExists int + if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM posts p WHERE p.id=? AND p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id))))`, id, current(r).ID, current(r).ID).Scan(&postExists); err != nil || postExists != 1 { + fail(w, http.StatusNotFound, 30001, "动态不存在") + return + } + if create { + var exists int + _ = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM post_likes WHERE post_id=? AND user_id=?)`, id, current(r).ID).Scan(&exists) + if exists == 0 { + if reserveErr := a.reserveDailyLike(tx, r, "post", id); reserveErr != nil { + _ = tx.Rollback() + if limitErr, ok := reserveErr.(*dailyLikeLimitError); ok { + fail(w, http.StatusTooManyRequests, 20002, limitErr.Error()) + return + } + fail(w, 500, 50001, "点赞失败") + return + } + } + result, execErr := tx.ExecContext(r.Context(), `INSERT IGNORE INTO post_likes(post_id,user_id)VALUES(?,?)`, id, current(r).ID) + if execErr != nil { + fail(w, 500, 50001, "点赞失败") + return + } + affected, _ := result.RowsAffected() + if affected > 0 { + if _, err = tx.ExecContext(r.Context(), `UPDATE posts SET like_count=like_count+1 WHERE id=?`, id); err != nil { + fail(w, 500, 50001, "点赞失败") + return + } + _, _ = tx.ExecContext(r.Context(), `INSERT INTO notifications(user_id,type,title,content,biz_type,biz_id) SELECT p.user_id,'like','动态获赞',?,'post',p.id FROM posts p LEFT JOIN user_notification_settings ns ON ns.user_id=p.user_id WHERE p.id=? AND p.user_id<>? AND COALESCE(ns.interaction_enabled,1)=1`, current(r).Name+" 赞了你的动态", id, current(r).ID) + } + } else { + result, execErr := tx.ExecContext(r.Context(), `DELETE FROM post_likes WHERE post_id=? AND user_id=?`, id, current(r).ID) + if execErr != nil { + fail(w, 500, 50001, "取消点赞失败") + return + } + affected, _ := result.RowsAffected() + if affected > 0 { + if _, err = tx.ExecContext(r.Context(), `UPDATE posts SET like_count=GREATEST(like_count-1,0) WHERE id=?`, id); err != nil { + fail(w, 500, 50001, "取消点赞失败") + return + } + } + } + if err = tx.Commit(); err != nil { + fail(w, 500, 50001, "保存点赞状态失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) comments(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态编号无效") + return + } + if _, err = a.loadPost(r, id); err != nil { + fail(w, http.StatusNotFound, 30001, "动态不存在") + return + } + page, pageSize, offset := pageOptions(r) + var total int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM post_comments WHERE post_id=? AND status=1`, id).Scan(&total); err != nil { + fail(w, 500, 50001, "查询失败") + return + } + rows, err := a.db.QueryContext(r.Context(), `SELECT c.id,c.content,c.created_at,p.user_id,p.nickname,p.avatar_url,c.parent_comment_id,c.reply_user_id,COALESCE(reply.nickname,'') FROM post_comments c JOIN user_profiles p ON p.user_id=c.user_id LEFT JOIN user_profiles reply ON reply.user_id=c.reply_user_id WHERE c.post_id=? AND c.status=1 ORDER BY c.created_at LIMIT ? OFFSET ?`, id, pageSize, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var cid, uid int64 + var content, nick, avatar, replyNickname string + var parentID, replyUserID sql.NullInt64 + var created time.Time + _ = rows.Scan(&cid, &content, &created, &uid, &nick, &avatar, &parentID, &replyUserID, &replyNickname) + items = append(items, map[string]any{"id": cid, "content": content, "createdAt": created, "parentCommentId": nullableInt64(parentID), "replyUser": map[string]any{"id": nullableInt64(replyUserID), "nickname": replyNickname}, "canDelete": uid == current(r).ID, "user": map[string]any{"id": uid, "nickname": nick, "avatar": avatar}}) + } + reply(w, map[string]any{"items": items, "total": total, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) +} + +func (a *App) createComment(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态编号无效") + return + } + var req struct { + Content string `json:"content"` + ParentCommentID *int64 `json:"parentCommentId"` + ReplyUserID *int64 `json:"replyUserId"` + } + req.Content = strings.TrimSpace(req.Content) + if decode(r, &req) != nil { + fail(w, 400, 20001, "评论格式错误") + return + } + req.Content = strings.TrimSpace(req.Content) + if req.Content == "" || len([]rune(req.Content)) > 1000 { + fail(w, 400, 20001, "评论不能为空") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "评论失败") + return + } + defer func() { _ = tx.Rollback() }() + var postExists int + if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM posts p WHERE p.id=? AND p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id))))`, id, current(r).ID, current(r).ID).Scan(&postExists); err != nil || postExists != 1 { + fail(w, http.StatusNotFound, 30001, "动态不存在") + return + } + if req.ParentCommentID != nil { + var replyUserID int64 + if err := tx.QueryRowContext(r.Context(), `SELECT user_id FROM post_comments WHERE id=? AND post_id=? AND status=1`, *req.ParentCommentID, id).Scan(&replyUserID); err != nil { + _ = tx.Rollback() + fail(w, 400, 20001, "回复的评论不存在") + return + } + if req.ReplyUserID == nil { + req.ReplyUserID = &replyUserID + } + } + result, err := tx.ExecContext(r.Context(), `INSERT INTO post_comments(post_id,user_id,parent_comment_id,reply_user_id,content)VALUES(?,?,?,?,?)`, id, current(r).ID, req.ParentCommentID, req.ReplyUserID, req.Content) + if err != nil { + _ = tx.Rollback() + fail(w, 500, 50001, "评论失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE posts SET comment_count=comment_count+1 WHERE id=?`, id); err != nil { + fail(w, 500, 50001, "评论失败") + return + } + _, _ = tx.ExecContext(r.Context(), `INSERT INTO notifications(user_id,type,title,content,biz_type,biz_id) SELECT p.user_id,'comment','新的评论',?,'post',p.id FROM posts p LEFT JOIN user_notification_settings ns ON ns.user_id=p.user_id WHERE p.id=? AND p.user_id<>? AND COALESCE(ns.interaction_enabled,1)=1`, current(r).Name+" 评论了你的动态", id, current(r).ID) + if err = tx.Commit(); err != nil { + fail(w, 500, 50001, "评论失败") + return + } + cid, _ := result.LastInsertId() + reply(w, map[string]any{"id": cid}) +} + +func (a *App) createReport(w http.ResponseWriter, r *http.Request) { + var req struct { + TargetType string `json:"targetType"` + TargetID int64 `json:"targetId"` + Reason string `json:"reason"` + Description string `json:"description"` + Evidence []string `json:"evidence"` + } + if decode(r, &req) != nil || req.TargetID == 0 { + fail(w, 400, 20001, "举报信息不完整") + return + } + req.TargetType = strings.ToLower(strings.TrimSpace(req.TargetType)) + req.Reason = strings.ToLower(strings.TrimSpace(req.Reason)) + req.Description = strings.TrimSpace(req.Description) + allowedTargets := map[string]bool{"user": true, "post": true, "comment": true, "message": true} + allowedReasons := map[string]bool{"fraud": true, "harassment": true, "pornography": true, "advertising": true, "violence": true, "minor_safety": true, "privacy": true, "other": true} + if !allowedTargets[req.TargetType] || !allowedReasons[req.Reason] || len([]rune(req.Description)) > 1000 || len(req.Evidence) > 6 { + fail(w, 400, 20001, "举报类型、原因或证据无效") + return + } + var targetExists int + switch req.TargetType { + case "user": + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM users WHERE id=? AND id<>? AND status=1 AND deleted_at IS NULL)`, req.TargetID, current(r).ID).Scan(&targetExists) + case "post": + if item, loadErr := a.loadPost(r, req.TargetID); loadErr == nil && item.User.ID != current(r).ID { + targetExists = 1 + } + case "comment": + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM post_comments c JOIN posts p ON p.id=c.post_id WHERE c.id=? AND c.status=1 AND c.user_id<>? AND p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id))))`, req.TargetID, current(r).ID, current(r).ID, current(r).ID).Scan(&targetExists) + case "message": + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM im_messages message JOIN im_conversation_members member ON member.conversation_id=message.conversation_id AND member.user_id=? AND member.status=1 WHERE message.id=? AND message.sender_id<>?)`, current(r).ID, req.TargetID, current(r).ID).Scan(&targetExists) + } + if targetExists != 1 { + fail(w, http.StatusNotFound, 30001, "举报目标不存在、不可访问或属于当前账号") + return + } + evidence, evidenceErr := a.validateOwnedImageEvidence(r.Context(), current(r).ID, req.Evidence) + if evidenceErr != nil { + fail(w, http.StatusBadRequest, 20001, evidenceErr.Error()) + return + } + var duplicate int + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM reports WHERE reporter_user_id=? AND target_type=? AND target_id=? AND status='PENDING' AND created_at>DATE_SUB(NOW(3),INTERVAL 24 HOUR))`, current(r).ID, req.TargetType, req.TargetID).Scan(&duplicate) + if duplicate == 1 { + fail(w, http.StatusConflict, 20001, "该内容已举报,请等待处理") + return + } + evidenceJSON, _ := json.Marshal(evidence) + result, err := a.db.ExecContext(r.Context(), `INSERT INTO reports(reporter_user_id,target_type,target_id,reason_code,description,evidence_json)VALUES(?,?,?,?,?,?)`, current(r).ID, req.TargetType, req.TargetID, req.Reason, req.Description, evidenceJSON) + if err != nil { + fail(w, 500, 50001, "提交失败") + return + } + id, _ := result.LastInsertId() + reply(w, map[string]any{"id": id, "status": "PENDING"}) +} + +func age(birthday time.Time) int { + now := time.Now() + years := now.Year() - birthday.Year() + if now.YearDay() < birthday.YearDay() { + years-- + } + return years +} +func haversine(lat1, lng1, lat2, lng2 float64) float64 { + const earth = 6371 + dlat := (lat2 - lat1) * math.Pi / 180 + dlng := (lng2 - lng1) * math.Pi / 180 + a := math.Sin(dlat/2)*math.Sin(dlat/2) + math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Sin(dlng/2)*math.Sin(dlng/2) + return earth * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) +} +func distanceText(value float64) string { + if value < 1 { + return fmt.Sprintf("%.2fkm", value) + } + return fmt.Sprintf("%.1fkm", value) +} +func sortProfilesByDistance(items []profileView) { + for i := 0; i < len(items); i++ { + for j := i + 1; j < len(items); j++ { + if items[j].Distance < items[i].Distance { + items[i], items[j] = items[j], items[i] + } + } + } +} +func jsonBytes(value any) []byte { data, _ := json.Marshal(value); return data } diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/storage_providers.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/storage_providers.go new file mode 100644 index 0000000..2089b48 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/storage_providers.go @@ -0,0 +1,258 @@ +package app + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + aliyunoss "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss" + aliyuncredentials "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials" + huaweiobs "github.com/huaweicloud/huaweicloud-sdk-go-obs/obs" + qiniuauth "github.com/qiniu/go-sdk/v7/auth/qbox" + qiniustorage "github.com/qiniu/go-sdk/v7/storage" + qiniucredentials "github.com/qiniu/go-sdk/v7/storagev2/credentials" + qiniuhttpclient "github.com/qiniu/go-sdk/v7/storagev2/http_client" + qiniuuploader "github.com/qiniu/go-sdk/v7/storagev2/uploader" + tencentcos "github.com/tencentyun/cos-go-sdk-v5" +) + +var ( + storageBucketPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{1,126}[A-Za-z0-9]$`) + storagePrefixPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9/_-]{0,119}$`) +) + +type mediaObjectStorage interface { + Put(context.Context, string, string, int64, io.Reader) error + Delete(context.Context, string) error + Bucket() string +} + +func storageProviderName(provider string) string { + switch provider { + case "local": + return "本地存储" + case "aliyun_oss": + return "阿里云 OSS" + case "tencent_cos": + return "腾讯云 COS" + case "qiniu": + return "七牛云存储" + case "huawei_obs": + return "华为云 OBS" + case "huawei_flexus": + return "华为云 Flexus 对象存储" + default: + return provider + } +} + +func parseStorageHTTPSURL(raw string, allowPath bool) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf("必须是无账号、查询参数和片段的有效 HTTPS 地址") + } + if !allowPath && strings.Trim(parsed.EscapedPath(), "/") != "" { + return nil, fmt.Errorf("地址不能包含路径") + } + return parsed, nil +} + +func validateLocalStorageDirectory(raw string) error { + directory := strings.TrimSpace(raw) + if directory == "" { + return fmt.Errorf("请填写本地存储目录") + } + abs, err := filepath.Abs(filepath.Clean(directory)) + if err != nil { + return fmt.Errorf("本地存储目录无效") + } + root := filepath.VolumeName(abs) + string(os.PathSeparator) + if strings.EqualFold(filepath.Clean(abs), filepath.Clean(root)) { + return fmt.Errorf("本地存储目录不能是磁盘根目录") + } + if info, statErr := os.Stat(abs); statErr == nil && !info.IsDir() { + return fmt.Errorf("本地存储目录指向了文件") + } else if statErr != nil && !os.IsNotExist(statErr) { + return fmt.Errorf("无法访问本地存储目录") + } + return nil +} + +func (a *App) validateStorageProviderConfig(ctx context.Context) error { + provider := a.configPlain(ctx, "storage.provider", "local") + providers := []string{"local", "aliyun_oss", "tencent_cos", "qiniu", "huawei_obs", "huawei_flexus"} + if !containsString(providers, provider) { + return fmt.Errorf("不支持的文件存储厂商 %q", provider) + } + for _, spec := range integrationSpecs["storage"] { + if !spec.Required || (len(spec.Providers) > 0 && !containsString(spec.Providers, provider)) { + continue + } + if strings.TrimSpace(a.configPlain(ctx, spec.Key, "")) == "" { + return fmt.Errorf("请填写%s", spec.Label) + } + } + prefix := strings.Trim(strings.TrimSpace(a.configPlain(ctx, "storage.object_prefix", "media")), "/") + if !storagePrefixPattern.MatchString(prefix) || strings.Contains(prefix, "//") || strings.Contains(prefix, "..") { + return fmt.Errorf("云端对象前缀格式无效") + } + + if provider == "local" { + if err := validateLocalStorageDirectory(a.configPlain(ctx, "storage.local.directory", a.config.MediaDir)); err != nil { + return err + } + baseURL := strings.TrimSpace(a.configPlain(ctx, "storage.local.public_base_url", "")) + if baseURL != "" { + parsed, err := url.Parse(baseURL) + if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return fmt.Errorf("本地公开访问地址无效") + } + if a.config.Environment == "production" && parsed.Scheme != "https" { + return fmt.Errorf("生产环境本地公开访问地址必须使用 HTTPS") + } + } + return nil + } + + keyPrefix := "storage." + provider + "." + if provider == "qiniu" { + if !storageBucketPattern.MatchString(a.configPlain(ctx, keyPrefix+"bucket", "")) { + return fmt.Errorf("七牛云空间名称格式无效") + } + } else { + if _, err := parseStorageHTTPSURL(a.configPlain(ctx, keyPrefix+"endpoint", ""), false); err != nil { + return fmt.Errorf("%s Endpoint %v", storageProviderName(provider), err) + } + if !storageBucketPattern.MatchString(a.configPlain(ctx, keyPrefix+"bucket", "")) { + return fmt.Errorf("%s Bucket 名称格式无效", storageProviderName(provider)) + } + } + if _, err := parseStorageHTTPSURL(a.configPlain(ctx, keyPrefix+"public_base_url", ""), true); err != nil { + return fmt.Errorf("%s文件访问域名%v", storageProviderName(provider), err) + } + return nil +} + +func storagePublicURL(baseURL, objectKey string) string { + return strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/" + strings.TrimLeft(objectKey, "/") +} + +func storageHTTPClient() *http.Client { + return &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +type aliyunOSSStorage struct { + bucket string + client *aliyunoss.Client +} + +func (s *aliyunOSSStorage) Bucket() string { return s.bucket } +func (s *aliyunOSSStorage) Put(ctx context.Context, key, contentType string, size int64, body io.Reader) error { + _, err := s.client.PutObject(ctx, &aliyunoss.PutObjectRequest{ + Bucket: aliyunoss.Ptr(s.bucket), Key: aliyunoss.Ptr(key), Body: body, + ContentType: aliyunoss.Ptr(contentType), ContentLength: aliyunoss.Ptr(size), + }) + return err +} +func (s *aliyunOSSStorage) Delete(ctx context.Context, key string) error { + _, err := s.client.DeleteObject(ctx, &aliyunoss.DeleteObjectRequest{Bucket: aliyunoss.Ptr(s.bucket), Key: aliyunoss.Ptr(key)}) + return err +} + +type tencentCOSStorage struct { + bucket string + client *tencentcos.Client +} + +func (s *tencentCOSStorage) Bucket() string { return s.bucket } +func (s *tencentCOSStorage) Put(ctx context.Context, key, contentType string, size int64, body io.Reader) error { + _, err := s.client.Object.Put(ctx, key, body, &tencentcos.ObjectPutOptions{ObjectPutHeaderOptions: &tencentcos.ObjectPutHeaderOptions{ContentType: contentType, ContentLength: size}}) + return err +} +func (s *tencentCOSStorage) Delete(ctx context.Context, key string) error { + _, err := s.client.Object.Delete(ctx, key) + return err +} + +type qiniuStorage struct { + bucket string + uploader *qiniuuploader.UploadManager + deleteMac *qiniuauth.Mac +} + +func (s *qiniuStorage) Bucket() string { return s.bucket } +func (s *qiniuStorage) Put(ctx context.Context, key, contentType string, _ int64, body io.Reader) error { + return s.uploader.UploadReader(ctx, body, &qiniuuploader.ObjectOptions{BucketName: s.bucket, ObjectName: &key, FileName: filepath.Base(key), ContentType: contentType}, nil) +} +func (s *qiniuStorage) Delete(_ context.Context, key string) error { + manager := qiniustorage.NewBucketManager(s.deleteMac, &qiniustorage.Config{UseHTTPS: true}) + return manager.Delete(s.bucket, key) +} + +type huaweiOBSStorage struct { + bucket string + client *huaweiobs.ObsClient +} + +func (s *huaweiOBSStorage) Bucket() string { return s.bucket } +func (s *huaweiOBSStorage) Put(_ context.Context, key, contentType string, size int64, body io.Reader) error { + _, err := s.client.PutObject(&huaweiobs.PutObjectInput{PutObjectBasicInput: huaweiobs.PutObjectBasicInput{ + ObjectOperationInput: huaweiobs.ObjectOperationInput{Bucket: s.bucket, Key: key}, + HttpHeader: huaweiobs.HttpHeader{ContentType: contentType}, ContentLength: size, + }, Body: body}) + return err +} +func (s *huaweiOBSStorage) Delete(_ context.Context, key string) error { + _, err := s.client.DeleteObject(&huaweiobs.DeleteObjectInput{Bucket: s.bucket, Key: key}) + return err +} + +func (a *App) newMediaObjectStorage(ctx context.Context) (mediaObjectStorage, func(), error) { + if err := a.validateStorageProviderConfig(ctx); err != nil { + return nil, func() {}, err + } + provider := a.configPlain(ctx, "storage.provider", "local") + keyPrefix := "storage." + provider + "." + switch provider { + case "aliyun_oss": + config := aliyunoss.LoadDefaultConfig(). + WithCredentialsProvider(aliyuncredentials.NewStaticCredentialsProvider(a.configPlain(ctx, keyPrefix+"access_key_id", ""), a.configPlain(ctx, keyPrefix+"access_key_secret", ""))). + WithRegion(a.configPlain(ctx, keyPrefix+"region", "")). + WithEndpoint(a.configPlain(ctx, keyPrefix+"endpoint", "")). + WithConnectTimeout(5 * time.Second). + WithReadWriteTimeout(30 * time.Second). + WithRetryMaxAttempts(3) + return &aliyunOSSStorage{bucket: a.configPlain(ctx, keyPrefix+"bucket", ""), client: aliyunoss.NewClient(config)}, func() {}, nil + case "tencent_cos": + endpoint, _ := url.Parse(a.configPlain(ctx, keyPrefix+"endpoint", "")) + client := tencentcos.NewClient(&tencentcos.BaseURL{BucketURL: endpoint}, &http.Client{ + Timeout: 30 * time.Second, + Transport: &tencentcos.AuthorizationTransport{SecretID: a.configPlain(ctx, keyPrefix+"secret_id", ""), SecretKey: a.configPlain(ctx, keyPrefix+"secret_key", "")}, + }) + return &tencentCOSStorage{bucket: a.configPlain(ctx, keyPrefix+"bucket", ""), client: client}, func() {}, nil + case "qiniu": + accessKey, secretKey := a.configPlain(ctx, keyPrefix+"access_key", ""), a.configPlain(ctx, keyPrefix+"secret_key", "") + manager := qiniuuploader.NewUploadManager(&qiniuuploader.UploadManagerOptions{Options: qiniuhttpclient.Options{Credentials: qiniucredentials.NewCredentials(accessKey, secretKey), BasicHTTPClient: storageHTTPClient()}, MultiPartsThreshold: 8 << 20, PartSize: 4 << 20, Concurrency: 2}) + return &qiniuStorage{bucket: a.configPlain(ctx, keyPrefix+"bucket", ""), uploader: manager, deleteMac: qiniuauth.NewMac(accessKey, secretKey)}, func() {}, nil + case "huawei_obs", "huawei_flexus": + client, err := huaweiobs.New(a.configPlain(ctx, keyPrefix+"access_key", ""), a.configPlain(ctx, keyPrefix+"secret_key", ""), a.configPlain(ctx, keyPrefix+"endpoint", ""), huaweiobs.WithConnectTimeout(5), huaweiobs.WithSocketTimeout(30), huaweiobs.WithMaxRetryCount(2)) + if err != nil { + return nil, func() {}, err + } + return &huaweiOBSStorage{bucket: a.configPlain(ctx, keyPrefix+"bucket", ""), client: client}, client.Close, nil + default: + return nil, func() {}, nil + } +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/storage_providers_test.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/storage_providers_test.go new file mode 100644 index 0000000..4bbd4ad --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/storage_providers_test.go @@ -0,0 +1,44 @@ +package app + +import ( + "path/filepath" + "testing" +) + +func TestParseStorageHTTPSURL(t *testing.T) { + tests := []struct { + url string + allowPath bool + valid bool + }{ + {url: "https://oss-cn-hangzhou.aliyuncs.com", valid: true}, + {url: "https://cdn.example.com/media", allowPath: true, valid: true}, + {url: "http://oss.example.com", valid: false}, + {url: "https://user:pass@oss.example.com", valid: false}, + {url: "https://oss.example.com/path", valid: false}, + {url: "https://oss.example.com?token=secret", valid: false}, + } + for _, test := range tests { + _, err := parseStorageHTTPSURL(test.url, test.allowPath) + if (err == nil) != test.valid { + t.Errorf("parseStorageHTTPSURL(%q, %v) error = %v, valid = %v", test.url, test.allowPath, err, test.valid) + } + } +} + +func TestValidateLocalStorageDirectoryRejectsRoot(t *testing.T) { + root := filepath.VolumeName(t.TempDir()) + string(filepath.Separator) + if err := validateLocalStorageDirectory(root); err == nil { + t.Fatalf("expected volume root %q to be rejected", root) + } + if err := validateLocalStorageDirectory(filepath.Join(t.TempDir(), "uploads")); err != nil { + t.Fatalf("expected nested upload directory to be accepted: %v", err) + } +} + +func TestStoragePublicURL(t *testing.T) { + got := storagePublicURL("https://cdn.example.com/media/", "/2026/08/test.png") + if got != "https://cdn.example.com/media/2026/08/test.png" { + t.Fatalf("unexpected public URL: %s", got) + } +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/test_users.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/test_users.go new file mode 100644 index 0000000..2617594 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/test_users.go @@ -0,0 +1,29 @@ +package app + +import ( + "fmt" + "strings" +) + +func testUserFilter(userType, batch string) (string, []any, error) { + where := "" + args := []any{} + switch userType { + case "": + case "test": + where = " AND u.is_test=1" + case "registered": + where = " AND u.is_test=0" + default: + return "", nil, fmt.Errorf("用户类型筛选无效") + } + batch = strings.TrimSpace(batch) + if len(batch) > 64 { + return "", nil, fmt.Errorf("测试批次长度不能超过64位") + } + if batch != "" { + where += " AND u.is_test=1 AND u.test_batch=?" + args = append(args, batch) + } + return where, args, nil +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/test_users_test.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/test_users_test.go new file mode 100644 index 0000000..c5addd7 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/test_users_test.go @@ -0,0 +1,41 @@ +package app + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestUserTypeFilters(t *testing.T) { + for _, item := range []struct{ kind, expected string }{{"", ""}, {"test", " AND u.is_test=1"}, {"registered", " AND u.is_test=0"}} { + where, args, err := testUserFilter(item.kind, "") + if err != nil || where != item.expected || len(args) != 0 { + t.Fatalf("filter %q: %q %v %v", item.kind, where, args, err) + } + } + if _, _, err := testUserFilter("invalid", ""); err == nil { + t.Fatal("invalid type accepted") + } + if _, _, err := testUserFilter("", strings.Repeat("x", 65)); err == nil { + t.Fatal("oversized batch accepted") + } + batch := "' OR 1=1 --" + where, args, err := testUserFilter("test", batch) + if err != nil || strings.Contains(where, batch) || !strings.Contains(where, "u.test_batch=?") || len(args) != 1 || args[0] != batch { + t.Fatal("batch must be an exact bound SQL value") + } +} + +func TestProfileExposesExplicitTestFlag(t *testing.T) { + for _, isTest := range []bool{false, true} { + data, err := json.Marshal(profileView{IsTest: isTest}) + if err != nil { + t.Fatal(err) + } + var result map[string]any + _ = json.Unmarshal(data, &result) + if result["isTest"] != isTest { + t.Fatalf("test label lost in profile JSON: %s", data) + } + } +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/app/user_oauth.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/user_oauth.go new file mode 100644 index 0000000..1a4880a --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/app/user_oauth.go @@ -0,0 +1,344 @@ +package app + +import ( + "context" + "database/sql" + "errors" + "net/http" + "net/url" + "strings" + "time" +) + +type userOAuthLoginCode struct { + Provider string + Subject string + Email string + DisplayName string + AvatarURL string + UserID sql.NullInt64 +} + +func (a *App) userOAuthFrontendURL(ctx context.Context) (string, error) { + raw := strings.TrimSpace(a.configPlain(ctx, "oauth.user.frontend_callback_url", "")) + if raw == "" { + return "", errors.New("客户端第三方登录结果页未配置") + } + if err := a.validateAdminOAuthRedirectURL(raw); err != nil { + return "", err + } + return raw, nil +} + +func (a *App) enabledUserOAuthProviders(ctx context.Context) ([]adminOAuthProvider, error) { + providers := make([]adminOAuthProvider, 0, len(adminOAuthProviderNames)) + for _, code := range []string{"wechat", "qq", "github", "google"} { + if !a.configBool(ctx, "oauth.user."+code+".enabled", false) { + continue + } + provider, err := a.adminOAuthProvider(ctx, code) + if err != nil { + return nil, err + } + providers = append(providers, provider) + } + return providers, nil +} + +// oauthCallback lets one provider callback URL safely serve both the admin +// console and the uni-app H5 client. The random state value selects the +// audience; it is never accepted by both state tables. +func (a *App) oauthCallback(w http.ResponseWriter, r *http.Request) { + state := strings.TrimSpace(r.URL.Query().Get("state")) + if state != "" { + var exists int + if a.db.QueryRowContext(r.Context(), `SELECT 1 FROM user_oauth_states WHERE state_hash=?`, oauthHash(state)).Scan(&exists) == nil { + a.userOAuthCallback(w, r) + return + } + if a.db.QueryRowContext(r.Context(), `SELECT 1 FROM admin_oauth_states WHERE state_hash=?`, oauthHash(state)).Scan(&exists) == nil { + a.adminOAuthCallback(w, r) + return + } + } + if strings.HasPrefix(r.URL.Path, "/api/") { + a.userOAuthCallback(w, r) + return + } + a.adminOAuthCallback(w, r) +} + +func (a *App) userOAuthProviders(w http.ResponseWriter, r *http.Request) { + items := make([]map[string]string, 0, len(adminOAuthProviderNames)) + for _, code := range []string{"wechat", "qq", "github", "google"} { + if !a.configBool(r.Context(), "oauth.user."+code+".enabled", false) { + continue + } + provider, err := a.adminOAuthProvider(r.Context(), code) + if err != nil { + continue + } + items = append(items, map[string]string{"code": provider.Code, "name": provider.Name}) + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) userOAuthStart(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "请选择第三方登录渠道") + return + } + req.Provider = strings.ToLower(strings.TrimSpace(req.Provider)) + if !a.rateLimit(w, r, "user_oauth_start", clientIP(r), 30, 10*time.Minute) { + return + } + if !a.configBool(r.Context(), "oauth.user."+req.Provider+".enabled", false) { + fail(w, http.StatusBadRequest, 20001, "该客户端登录方式未启用") + return + } + provider, err := a.adminOAuthProvider(r.Context(), req.Provider) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "该登录方式配置不完整") + return + } + if _, err = a.userOAuthFrontendURL(r.Context()); err != nil { + fail(w, http.StatusBadRequest, 20001, "客户端登录结果页配置不完整") + return + } + state := randomToken() + verifier := "" + if provider.Code == "github" || provider.Code == "google" { + verifier = randomToken() + randomToken() + } + _, err = a.db.ExecContext(r.Context(), `INSERT INTO user_oauth_states(state_hash,provider,code_verifier,expires_at) VALUES(?,?,?,?)`, oauthHash(state), provider.Code, verifier, time.Now().Add(adminOAuthStateTTL)) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建第三方登录请求失败") + return + } + a.cleanupUserOAuthRecords(r.Context()) + + authorizationURL, _ := url.Parse(provider.AuthorizationURL) + query := authorizationURL.Query() + if provider.Code == "wechat" { + query.Set("appid", provider.ClientID) + } else { + query.Set("client_id", provider.ClientID) + } + query.Set("redirect_uri", provider.RedirectURI) + query.Set("response_type", "code") + query.Set("scope", provider.Scope) + query.Set("state", state) + if verifier != "" { + query.Set("code_challenge", pkceChallenge(verifier)) + query.Set("code_challenge_method", "S256") + } + authorizationURL.RawQuery = query.Encode() + if provider.Code == "wechat" { + authorizationURL.Fragment = "wechat_redirect" + } + reply(w, map[string]string{"authorizationUrl": authorizationURL.String(), "provider": provider.Code}) +} + +func (a *App) userOAuthCallback(w http.ResponseWriter, r *http.Request) { + frontendURL, err := a.userOAuthFrontendURL(r.Context()) + if err != nil { + fail(w, http.StatusServiceUnavailable, 50001, "客户端第三方登录回调未配置") + return + } + state := strings.TrimSpace(r.URL.Query().Get("state")) + if state == "" { + a.redirectUserOAuthResult(w, r, frontendURL, "", "登录状态无效或已过期") + return + } + var providerCode, verifier string + err = a.db.QueryRowContext(r.Context(), `SELECT provider,code_verifier FROM user_oauth_states WHERE state_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(state)).Scan(&providerCode, &verifier) + if err != nil { + a.redirectUserOAuthResult(w, r, frontendURL, "", "登录状态无效或已过期") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE user_oauth_states SET used_at=NOW(3) WHERE state_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(state)) + if err != nil { + a.redirectUserOAuthResult(w, r, frontendURL, "", "第三方登录处理失败") + return + } + affected, _ := result.RowsAffected() + if affected != 1 { + a.redirectUserOAuthResult(w, r, frontendURL, "", "登录状态已被使用") + return + } + if strings.TrimSpace(r.URL.Query().Get("error")) != "" { + a.redirectUserOAuthResult(w, r, frontendURL, "", "第三方授权已取消或失败") + return + } + code := strings.TrimSpace(r.URL.Query().Get("code")) + if code == "" { + a.redirectUserOAuthResult(w, r, frontendURL, "", "第三方平台未返回授权码") + return + } + if !a.configBool(r.Context(), "oauth.user."+providerCode+".enabled", false) { + a.redirectUserOAuthResult(w, r, frontendURL, "", "该客户端登录方式已停用") + return + } + provider, err := a.adminOAuthProvider(r.Context(), providerCode) + if err != nil { + a.redirectUserOAuthResult(w, r, frontendURL, "", "该登录方式配置不可用") + return + } + identity, err := a.fetchAdminOAuthIdentity(r.Context(), provider, code, verifier) + if err != nil { + a.redirectUserOAuthResult(w, r, frontendURL, "", "获取第三方账号信息失败") + return + } + var userID sql.NullInt64 + _ = a.db.QueryRowContext(r.Context(), `SELECT user_id FROM user_oauth_identities WHERE provider=? AND subject=?`, provider.Code, identity.Subject).Scan(&userID) + loginCode := randomToken() + _, err = a.db.ExecContext(r.Context(), `INSERT INTO user_oauth_login_codes(code_hash,provider,subject,email,display_name,avatar_url,user_id,expires_at) VALUES(?,?,?,?,?,?,?,?)`, oauthHash(loginCode), provider.Code, identity.Subject, identity.Email, identity.DisplayName, identity.AvatarURL, userID, time.Now().Add(adminOAuthCodeTTL)) + if err != nil { + a.redirectUserOAuthResult(w, r, frontendURL, "", "创建登录凭证失败") + return + } + a.redirectUserOAuthResult(w, r, frontendURL, loginCode, "") +} + +func (a *App) redirectUserOAuthResult(w http.ResponseWriter, r *http.Request, frontendURL, code, message string) { + target, err := url.Parse(frontendURL) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "客户端登录结果页地址无效") + return + } + query := target.Query() + if code != "" { + query.Set("oauthCode", code) + } else { + query.Set("oauthError", message) + } + target.RawQuery = query.Encode() + http.Redirect(w, r, target.String(), http.StatusFound) +} + +func (a *App) userOAuthExchange(w http.ResponseWriter, r *http.Request) { + var req struct { + Code string `json:"code"` + DeviceID string `json:"deviceId"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效") + return + } + if !a.rateLimit(w, r, "user_oauth_exchange", clientIP(r), 20, 10*time.Minute) { + return + } + loginCode, err := a.readUserOAuthLoginCode(r.Context(), strings.TrimSpace(req.Code)) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效或已过期") + return + } + if !loginCode.UserID.Valid { + reply(w, map[string]any{ + "requiresLink": true, + "provider": loginCode.Provider, + "providerName": adminOAuthProviderNames[loginCode.Provider], + "displayName": loginCode.DisplayName, + "avatarUrl": loginCode.AvatarURL, + }) + return + } + userID, nickname, err := a.consumeUserOAuthCode(r.Context(), strings.TrimSpace(req.Code), loginCode.UserID.Int64) + if err != nil { + fail(w, http.StatusUnauthorized, 10001, err.Error()) + return + } + a.finishLogin(w, r, userID, nickname, req.DeviceID) +} + +func (a *App) userOAuthLink(w http.ResponseWriter, r *http.Request) { + var req struct { + Code string `json:"code"` + Phone string `json:"phone"` + SMSCode string `json:"smsCode"` + DeviceID string `json:"deviceId"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" || !validPhone(req.Phone) || len(req.SMSCode) != 6 { + fail(w, http.StatusBadRequest, 20001, "请输入已注册手机号和正确的短信验证码") + return + } + phone := strings.TrimSpace(req.Phone) + if !a.rateLimit(w, r, "user_oauth_link_ip", clientIP(r), 10, 15*time.Minute) || !a.rateLimit(w, r, "user_oauth_link_phone", phone, 10, 15*time.Minute) { + return + } + loginCode, err := a.readUserOAuthLoginCode(r.Context(), strings.TrimSpace(req.Code)) + if err != nil || loginCode.UserID.Valid { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效、已绑定或已过期") + return + } + var userID int64 + var nickname string + var status int + err = a.db.QueryRowContext(r.Context(), `SELECT u.id,p.nickname,u.status FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.phone_hash=? AND u.deleted_at IS NULL`, phoneHash(phone)).Scan(&userID, &nickname, &status) + if err != nil || status != 1 { + fail(w, http.StatusBadRequest, 20001, "手机号未注册或账号当前不可用") + return + } + if !a.consumeSMSCode(r, phone, "login", req.SMSCode) { + fail(w, http.StatusBadRequest, 20001, "验证码错误或已过期") + return + } + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(r.Context(), `UPDATE user_oauth_login_codes SET used_at=NOW(3),user_id=? WHERE code_hash=? AND user_id IS NULL AND used_at IS NULL AND expires_at>NOW(3)`, userID, oauthHash(strings.TrimSpace(req.Code))) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + affected, _ := result.RowsAffected() + if affected != 1 { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证已被使用") + return + } + _, err = tx.ExecContext(r.Context(), `INSERT INTO user_oauth_identities(provider,subject,user_id,email,display_name,avatar_url,last_login_at) VALUES(?,?,?,?,?,?,NOW(3))`, loginCode.Provider, loginCode.Subject, userID, loginCode.Email, loginCode.DisplayName, loginCode.AvatarURL) + if err != nil { + fail(w, http.StatusConflict, 20001, "该第三方账号或手机号已绑定此渠道") + return + } + if err = tx.Commit(); err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + a.finishLogin(w, r, userID, nickname, req.DeviceID) +} + +func (a *App) readUserOAuthLoginCode(ctx context.Context, code string) (userOAuthLoginCode, error) { + var result userOAuthLoginCode + err := a.db.QueryRowContext(ctx, `SELECT provider,subject,email,display_name,avatar_url,user_id FROM user_oauth_login_codes WHERE code_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(code)).Scan(&result.Provider, &result.Subject, &result.Email, &result.DisplayName, &result.AvatarURL, &result.UserID) + return result, err +} + +func (a *App) consumeUserOAuthCode(ctx context.Context, code string, userID int64) (int64, string, error) { + var nickname string + var status int + if err := a.db.QueryRowContext(ctx, `SELECT p.nickname,u.status FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.id=? AND u.deleted_at IS NULL`, userID).Scan(&nickname, &status); err != nil || status != 1 { + return 0, "", errors.New("账号不存在或当前不可用") + } + result, err := a.db.ExecContext(ctx, `UPDATE user_oauth_login_codes SET used_at=NOW(3) WHERE code_hash=? AND user_id=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(code), userID) + if err != nil { + return 0, "", errors.New("第三方登录处理失败") + } + affected, _ := result.RowsAffected() + if affected != 1 { + return 0, "", errors.New("第三方登录凭证无效或已使用") + } + _, _ = a.db.ExecContext(ctx, `UPDATE user_oauth_identities SET last_login_at=NOW(3) WHERE user_id=?`, userID) + return userID, nickname, nil +} + +func (a *App) cleanupUserOAuthRecords(ctx context.Context) { + _, _ = a.db.ExecContext(ctx, `DELETE FROM user_oauth_states WHERE expires_at 50 || len([]rune(documentNumber)) < 6 || len([]rune(documentNumber)) > 80 { + fail(w, http.StatusBadRequest, 20001, "姓名或证件号码格式无效") + return + } + if len(req.Evidence) == 0 || len(req.Evidence) > 3 { + fail(w, http.StatusBadRequest, 20001, "请上传 1 至 3 张认证材料") + return + } + + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "提交认证失败") + return + } + defer func() { _ = tx.Rollback() }() + var existingStatus string + err = tx.QueryRowContext(r.Context(), `SELECT status FROM user_verifications WHERE user_id=? AND verification_type=? FOR UPDATE`, current(r).ID, req.Type).Scan(&existingStatus) + if err != nil && err != sql.ErrNoRows { + fail(w, http.StatusInternalServerError, 50001, "读取认证状态失败") + return + } + if existingStatus == "PENDING" { + fail(w, http.StatusConflict, 20001, "认证资料正在审核,请勿重复提交") + return + } + if existingStatus == "VERIFIED" { + fail(w, http.StatusConflict, 20001, "账号已完成认证") + return + } + + evidence := make([]string, 0, len(req.Evidence)) + seen := map[string]bool{} + for _, rawURL := range req.Evidence { + mediaURL := strings.TrimSpace(rawURL) + if mediaURL == "" || seen[mediaURL] { + fail(w, http.StatusBadRequest, 20001, "认证材料地址无效或重复") + return + } + seen[mediaURL] = true + var exists int + if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM media_assets WHERE owner_user_id=? AND public_url=? AND media_type='image' AND status=1)`, current(r).ID, mediaURL).Scan(&exists); err != nil || exists != 1 { + fail(w, http.StatusBadRequest, 20001, "认证材料必须由当前账号上传") + return + } + evidence = append(evidence, mediaURL) + } + evidenceJSON, _ := json.Marshal(evidence) + _, err = tx.ExecContext(r.Context(), `INSERT INTO user_verifications(user_id,verification_type,status,real_name,document_mask,document_hash,evidence_json,remark,reviewer_admin_id,submitted_at,reviewed_at) + VALUES(?,?,'PENDING',?,?,?,?, '',NULL,NOW(3),NULL) + ON DUPLICATE KEY UPDATE verification_type=VALUES(verification_type),status='PENDING',real_name=VALUES(real_name),document_mask=VALUES(document_mask),document_hash=VALUES(document_hash),evidence_json=VALUES(evidence_json),remark='',reviewer_admin_id=NULL,submitted_at=NOW(3),reviewed_at=NULL`, current(r).ID, req.Type, req.RealName, maskDocumentNumber(documentNumber), a.verificationDocumentHash(documentNumber), string(evidenceJSON)) + if err != nil || tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "提交认证失败") + return + } + reply(w, map[string]any{"status": "PENDING", "submitted": true}) +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/testusers/assets.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/testusers/assets.go new file mode 100644 index 0000000..2ecff98 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/testusers/assets.go @@ -0,0 +1,57 @@ +package testusers + +import ( + "bytes" + "fmt" + "image/png" + "os" + "path/filepath" +) + +// CopyAvatars validates all sources and destination collisions before copying. +// Existing identical assets are reused. Existing different files are not replaced. +func CopyAvatars(sourceDir, mediaDir string) error { + if sourceDir == "" || mediaDir == "" { + return fmt.Errorf("both avatar source and media directories are required") + } + assets := make(map[string][]byte) + for _, name := range Avatars() { + data, err := os.ReadFile(filepath.Join(sourceDir, name)) + if err != nil { + return err + } + cfg, err := png.DecodeConfig(bytes.NewReader(data)) + if err != nil || cfg.Width < 256 || cfg.Height < 256 { + return fmt.Errorf("invalid or undersized PNG avatar: %s", name) + } + existing, err := os.ReadFile(filepath.Join(mediaDir, name)) + if err == nil { + if !bytes.Equal(existing, data) { + return fmt.Errorf("refusing to overwrite different existing avatar: %s", name) + } + continue + } + if !os.IsNotExist(err) { + return err + } + assets[name] = data + } + if err := os.MkdirAll(mediaDir, 0755); err != nil { + return err + } + for name, data := range assets { + file, err := os.OpenFile(filepath.Join(mediaDir, name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) + if err != nil { + return err + } + _, writeErr := file.Write(data) + closeErr := file.Close() + if writeErr != nil { + return writeErr + } + if closeErr != nil { + return closeErr + } + } + return nil +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/testusers/fixtures.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/testusers/fixtures.go new file mode 100644 index 0000000..acd4b74 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/testusers/fixtures.go @@ -0,0 +1,75 @@ +// Package testusers creates explicitly labelled, non-loginable test profiles. +// It is only used by an operator-invoked CLI, never during server startup. +package testusers + +import ( + "fmt" + "net/url" + "strings" +) + +const Batch = "cn-adults-20260831-v1" +const Count = 100 + +type Profile struct { + PublicID string `json:"publicId"` + IsTest bool `json:"isTest"` + TestBatch string `json:"testBatch"` + Label string `json:"label"` + Nickname string `json:"nickname"` + Gender int `json:"gender"` + Birthday string `json:"birthday"` + Height int `json:"height"` + CityCode string `json:"cityCode"` + City string `json:"city"` + Occupation string `json:"occupation"` + Bio string `json:"bio"` + AvatarFile string `json:"avatarFile"` + Avatar string `json:"avatar"` +} + +// Avatars contains five independently generated adult portraits per gender. +// Reuse is intentional for fixtures; these are not 100 real identities. +func Avatars() []string { + files := make([]string, 0, 10) + for gender := 1; gender <= 2; gender++ { + for n := 1; n <= 5; n++ { + files = append(files, fmt.Sprintf("900028%d%d-1.png", gender, n)) + } + } + return files +} + +func Generate(publicBase string) ([]Profile, error) { + base, err := url.Parse(strings.TrimRight(publicBase, "/")) + if err != nil || base.Host == "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" || (base.Scheme != "http" && base.Scheme != "https") { + return nil, fmt.Errorf("public base must be an absolute HTTP(S) uploads URL without credentials, query or fragment") + } + if base.Scheme == "http" && base.Hostname() != "127.0.0.1" && base.Hostname() != "localhost" && base.Hostname() != "::1" { + return nil, fmt.Errorf("non-local avatar URLs require HTTPS") + } + surnames := []string{"陈", "林", "周", "许", "苏", "沈", "陆", "顾", "方", "季"} + given := [][]string{{"沐川", "知远", "星河", "景行", "予安"}, {"晚晴", "知夏", "语桐", "清禾", "若宁"}} + cities := [][2]string{{"310100", "上海"}, {"440100", "广州"}, {"440300", "深圳"}, {"330100", "杭州"}, {"510100", "成都"}, {"420100", "武汉"}, {"320100", "南京"}, {"350200", "厦门"}, {"610100", "西安"}, {"370200", "青岛"}} + jobs := []string{"设计师", "工程师", "教师", "摄影师", "产品经理"} + hobbies := []string{"摄影与城市漫步", "阅读与咖啡", "跑步与音乐", "旅行与美食", "电影与绘画"} + ages := [2][5]int{{27, 30, 24, 33, 28}, {26, 29, 24, 32, 27}} + items := make([]Profile, 0, Count) + for gender := 1; gender <= 2; gender++ { + for i := 0; i < 50; i++ { + avatarFile := fmt.Sprintf("900028%d%d-1.png", gender, i%5+1) + city := cities[i%len(cities)] + items = append(items, Profile{ + PublicID: fmt.Sprintf("TESTCN%06d", (gender-1)*50+i+1), + IsTest: true, TestBatch: Batch, Label: "测试用户", + Nickname: "测试·" + surnames[i/5] + given[gender-1][i%5], + Gender: gender, Birthday: fmt.Sprintf("%d-%02d-%02d", 2026-ages[gender-1][i%5], i%6+1, i%27+1), + Height: 160 + (2-gender)*12 + i%12, + CityCode: city[0], City: city[1], Occupation: jobs[i%len(jobs)], + Bio: "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:" + hobbies[i%len(hobbies)] + "。", + AvatarFile: avatarFile, Avatar: base.String() + "/" + avatarFile, + }) + } + } + return items, nil +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/testusers/fixtures_test.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/testusers/fixtures_test.go new file mode 100644 index 0000000..568634a --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/testusers/fixtures_test.go @@ -0,0 +1,98 @@ +package testusers + +import ( + "bytes" + "image" + "image/png" + "os" + "path/filepath" + "reflect" + "regexp" + "strings" + "testing" + "time" +) + +func TestGenerateBalancedLabelledAdults(t *testing.T) { + items, err := Generate("https://im.bchongw.com/uploads/") + if err != nil || len(items) != 100 { + t.Fatalf("len=%d err=%v", len(items), err) + } + ids, names := map[string]bool{}, map[string]bool{} + counts := map[int]int{} + files := map[string]bool{} + for _, file := range Avatars() { + files[file] = true + } + for _, p := range items { + if ids[p.PublicID] || names[p.Nickname] || !p.IsTest || p.TestBatch != Batch || p.Label != "测试用户" || !strings.HasPrefix(p.Nickname, "测试·") { + t.Fatalf("missing label or duplicate identity: %+v", p) + } + ids[p.PublicID], names[p.Nickname] = true, true + counts[p.Gender]++ + birthday, err := time.Parse("2006-01-02", p.Birthday) + if err != nil || birthday.After(time.Date(2008, 8, 31, 0, 0, 0, 0, time.UTC)) { + t.Fatalf("invalid adult birthday: %s", p.Birthday) + } + if !files[p.AvatarFile] || p.Avatar != "https://im.bchongw.com/uploads/"+p.AvatarFile || !regexp.MustCompile(`^[0-9]+-[0-9]+\.png$`).MatchString(p.AvatarFile) { + t.Fatalf("avatar incompatible with media route: %s", p.Avatar) + } + if !strings.Contains(p.Bio, "非真实交友用户") { + t.Fatal("missing synthetic identity disclosure") + } + } + if counts[1] != 50 || counts[2] != 50 || len(counts) != 2 { + t.Fatalf("gender distribution: %v", counts) + } + again, _ := Generate("https://im.bchongw.com/uploads/") + if !reflect.DeepEqual(items, again) { + t.Fatal("fixture generation must be deterministic") + } +} + +func TestRejectUnsafePublicBase(t *testing.T) { + for _, base := range []string{"", "/uploads", "http://example.com/uploads", "https://user:secret@example.com/uploads", "https://example.com/uploads?x=1", "https://example.com/#bad", "javascript:alert(1)"} { + if _, err := Generate(base); err == nil { + t.Errorf("accepted unsafe base %q", base) + } + } + for _, base := range []string{"https://example.com/uploads", "http://127.0.0.1:8888/uploads", "http://localhost:8888/uploads"} { + if _, err := Generate(base); err != nil { + t.Errorf("rejected valid base %q: %v", base, err) + } + } +} + +func TestAvatarCopyIsRepeatableAndDoesNotOverwrite(t *testing.T) { + source, destination := t.TempDir(), t.TempDir() + var buf bytes.Buffer + if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 256, 256))); err != nil { + t.Fatal(err) + } + for _, file := range Avatars() { + if err := os.WriteFile(filepath.Join(source, file), buf.Bytes(), 0600); err != nil { + t.Fatal(err) + } + } + for i := 0; i < 2; i++ { + if err := CopyAvatars(source, destination); err != nil { + t.Fatal(err) + } + } + conflict := filepath.Join(destination, Avatars()[0]) + if err := os.WriteFile(conflict, []byte("existing user file"), 0600); err != nil { + t.Fatal(err) + } + if err := CopyAvatars(source, destination); err == nil { + t.Fatal("overwrote an unrelated file") + } + if got, _ := os.ReadFile(conflict); string(got) != "existing user file" { + t.Fatal("collision changed existing file") + } +} + +func TestBundledAvatarsArePresent(t *testing.T) { + if err := CopyAvatars(filepath.Join("..", "..", "..", "fixtures", "test-users", "avatars"), t.TempDir()); err != nil { + t.Fatal(err) + } +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/testusers/seed.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/testusers/seed.go new file mode 100644 index 0000000..12e0616 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/testusers/seed.go @@ -0,0 +1,107 @@ +package testusers + +import ( + "context" + "database/sql" + "fmt" +) + +type Result struct { + Batch string `json:"batch"` + Created int `json:"created"` + Skipped int `json:"skipped"` + Male int `json:"male"` + Female int `json:"female"` + IDs []int64 `json:"ids"` +} + +// Seed is atomic and repeatable: rerunning a complete batch is a no-op. +// Conflicting real users and incomplete/modified batches are never overwritten. +func Seed(ctx context.Context, db *sql.DB, publicBase, confirmDatabase string) (Result, error) { + result := Result{Batch: Batch, IDs: []int64{}} + profiles, err := Generate(publicBase) + if err != nil { + return result, err + } + conn, err := db.Conn(ctx) + if err != nil { + return result, err + } + defer conn.Close() + var database string + if err = conn.QueryRowContext(ctx, `SELECT DATABASE()`).Scan(&database); err != nil { + return result, err + } + if confirmDatabase == "" || database != confirmDatabase { + return result, fmt.Errorf("database confirmation does not match the connected database") + } + var locked int + if err = conn.QueryRowContext(ctx, `SELECT GET_LOCK(?,10)`, "xingyu:testusers:"+Batch).Scan(&locked); err != nil || locked != 1 { + return result, fmt.Errorf("could not acquire test-user batch lock") + } + defer func() { _, _ = conn.ExecContext(context.Background(), `DO RELEASE_LOCK(?)`, "xingyu:testusers:"+Batch) }() + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + return result, err + } + defer tx.Rollback() + var batchCount int + if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE test_batch=?`, Batch).Scan(&batchCount); err != nil { + return result, fmt.Errorf("test-user schema unavailable; apply migration 028 first: %w", err) + } + if batchCount != 0 && batchCount != Count { + return result, fmt.Errorf("batch has %d users, expected 0 or %d; refusing to modify partial data", batchCount, Count) + } + for _, p := range profiles { + var id int64 + var isTest bool + var existingBatch string + var deleted sql.NullTime + err = tx.QueryRowContext(ctx, `SELECT id,is_test,test_batch,deleted_at FROM users WHERE public_id=? FOR UPDATE`, p.PublicID).Scan(&id, &isTest, &existingBatch, &deleted) + if err == nil { + if batchCount != Count || !isTest || existingBatch != Batch || deleted.Valid { + return Result{}, fmt.Errorf("public ID %s conflicts with an existing or deleted user; no users changed", p.PublicID) + } + var gender int + if err = tx.QueryRowContext(ctx, `SELECT gender FROM user_profiles WHERE user_id=?`, id).Scan(&gender); err != nil || gender != p.Gender { + return Result{}, fmt.Errorf("existing test profile %s has been modified or is incomplete", p.PublicID) + } + result.Skipped++ + } else if err == sql.ErrNoRows && batchCount == 0 { + res, insertErr := tx.ExecContext(ctx, `INSERT INTO users (public_id,password_hash,is_test,test_batch,status) VALUES (?,'!TEST_PROFILE_NO_LOGIN',1,?,1)`, p.PublicID, Batch) + if insertErr != nil { + return Result{}, insertErr + } + id, err = res.LastInsertId() + if err != nil { + return Result{}, err + } + _, err = tx.ExecContext(ctx, `INSERT INTO user_profiles (user_id,nickname,avatar_url,cover_url,gender,birthday,height_cm,city_code,city_name,occupation,bio,profile_score) VALUES (?,?,?,'',?,?,?,?,?,?,?,80)`, id, p.Nickname, p.Avatar, p.Gender, p.Birthday, p.Height, p.CityCode, p.City, p.Occupation, p.Bio) + if err != nil { + return Result{}, err + } + // Do not invent GPS, recent activity, identity verification or memberships. + // An empty phone and invalid password hash make these display-only fixtures. + _, err = tx.ExecContext(ctx, `INSERT INTO user_privacy_settings (user_id,distance_visible,online_visible,last_active_visible,allow_profile_visit_record) VALUES (?,0,0,0,0)`, id) + if err != nil { + return Result{}, err + } + result.Created++ + } else { + if err == sql.ErrNoRows { + err = fmt.Errorf("batch identity mismatch at %s", p.PublicID) + } + return Result{}, err + } + if p.Gender == 1 { + result.Male++ + } else { + result.Female++ + } + result.IDs = append(result.IDs, id) + } + if err = tx.Commit(); err != nil { + return Result{}, err + } + return result, nil +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/internal/testusers/seed_integration_test.go b/im/.deploy/application-20260831-120612/rollback-backend/internal/testusers/seed_integration_test.go new file mode 100644 index 0000000..d997b06 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/internal/testusers/seed_integration_test.go @@ -0,0 +1,132 @@ +package testusers + +import ( + "context" + "database/sql" + "fmt" + "net" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/go-sql-driver/mysql" +) + +// This test never connects to the application database. It creates its own +// uniquely named schema on an explicitly supplied loopback MySQL connection. +func isolatedMySQL(t *testing.T) *sql.DB { + t.Helper() + dsn := os.Getenv("IM_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("set IM_TEST_MYSQL_DSN to enable isolated local MySQL integration tests") + } + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + t.Fatal("invalid test MySQL DSN") + } + host, _, err := net.SplitHostPort(cfg.Addr) + if err != nil || cfg.Net != "tcp" || (host != "127.0.0.1" && host != "localhost" && host != "::1") || cfg.DBName != "" { + t.Fatal("integration tests require a loopback TCP DSN without a database name") + } + cfg.ParseTime = true + admin, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { admin.Close() }) + database := fmt.Sprintf("im_fixture_test_%d", time.Now().UnixNano()) + if !regexp.MustCompile(`^im_fixture_test_[0-9]+$`).MatchString(database) { + t.Fatal("unsafe isolated database name") + } + if _, err = admin.Exec("CREATE DATABASE `" + database + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil { + t.Fatal(err) + } + // Only drop the exact schema successfully created by this test. + t.Cleanup(func() { + if _, err := admin.Exec("DROP DATABASE `" + database + "`"); err != nil { + t.Errorf("cleanup isolated schema %s: %v", database, err) + } + }) + cfg.DBName, cfg.MultiStatements = database, true + db, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + for _, name := range []string{"001_users.sql", "028_test_users.sql"} { + data, err := os.ReadFile(filepath.Join("..", "..", "migrations", name)) + if err != nil { + t.Fatal(err) + } + if _, err = db.Exec(string(data)); err != nil { + t.Fatal(err) + } + } + return db +} + +func TestMySQLSeedRepeatabilityAndIsolation(t *testing.T) { + db := isolatedMySQL(t) + var database string + if err := db.QueryRow(`SELECT DATABASE()`).Scan(&database); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO users (public_id,password_hash) VALUES ('REAL_FIXTURE','sentinel')`); err != nil { + t.Fatal(err) + } + ctx := context.Background() + if _, err := Seed(ctx, db, "https://example.com/uploads", "wrong_database"); err == nil { + t.Fatal("missing target-database protection") + } + first, err := Seed(ctx, db, "https://example.com/uploads", database) + if err != nil || first.Created != 100 || first.Male != 50 || first.Female != 50 || len(first.IDs) != 100 { + t.Fatalf("first seed: %+v, %v", first, err) + } + second, err := Seed(ctx, db, "https://example.com/uploads", database) + if err != nil || second.Created != 0 || second.Skipped != 100 || second.IDs[0] != first.IDs[0] { + t.Fatalf("repeat seed: %+v, %v", second, err) + } + for query, want := range map[string]int{ + `SELECT COUNT(*) FROM users WHERE is_test=0 AND test_batch='' AND password_hash='sentinel'`: 1, + `SELECT COUNT(*) FROM users WHERE is_test=1 AND phone_hash IS NULL AND phone_cipher IS NULL AND password_hash='!TEST_PROFILE_NO_LOGIN'`: 100, + `SELECT COUNT(*) FROM user_profiles WHERE last_active_at IS NULL AND is_vip=0 AND vip_level=0`: 100, + `SELECT COUNT(*) FROM user_profiles WHERE gender=1`: 50, + `SELECT COUNT(*) FROM user_profiles WHERE gender=2`: 50, + `SELECT COUNT(*) FROM user_sessions`: 0, + } { + var got int + if err := db.QueryRow(query).Scan(&got); err != nil || got != want { + t.Errorf("query %s: got %d want %d err %v", query, got, want, err) + } + } + if _, err := db.Exec(`UPDATE users SET test_batch='changed' WHERE public_id='TESTCN000100'`); err != nil { + t.Fatal(err) + } + if _, err := Seed(ctx, db, "https://example.com/uploads", database); err == nil || !strings.Contains(err.Error(), "expected 0 or 100") { + t.Fatalf("partial batch should not be silently repaired: %v", err) + } +} + +func TestMySQLSeedRollsBackOnRealUserCollision(t *testing.T) { + db := isolatedMySQL(t) + var database string + if err := db.QueryRow(`SELECT DATABASE()`).Scan(&database); err != nil { + t.Fatal(err) + } + // Force a late collision to prove earlier inserts in this batch roll back. + if _, err := db.Exec(`INSERT INTO users (public_id,password_hash) VALUES ('TESTCN000099','real-user-sentinel')`); err != nil { + t.Fatal(err) + } + if _, err := Seed(context.Background(), db, "https://example.com/uploads", database); err == nil { + t.Fatal("real-user collision must fail") + } + var total, tests, profiles int + _ = db.QueryRow(`SELECT COUNT(*),COALESCE(SUM(is_test),0) FROM users`).Scan(&total, &tests) + _ = db.QueryRow(`SELECT COUNT(*) FROM user_profiles`).Scan(&profiles) + if total != 1 || tests != 0 || profiles != 0 { + t.Fatalf("partial seed survived rollback: users=%d tests=%d profiles=%d", total, tests, profiles) + } +} diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/001_users.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/001_users.sql new file mode 100644 index 0000000..03659f5 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/001_users.sql @@ -0,0 +1,123 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + public_id VARCHAR(20) NOT NULL, + country_code VARCHAR(8) NOT NULL DEFAULT '+86', + phone_hash BINARY(32) NULL, + phone_cipher VARBINARY(255) NULL, + password_hash VARCHAR(255) NOT NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + risk_level TINYINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_users_public_id (public_id), + UNIQUE KEY uk_users_phone_hash (phone_hash), + KEY idx_users_status_created (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_profiles ( + user_id BIGINT UNSIGNED NOT NULL, + nickname VARCHAR(50) NOT NULL, + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + cover_url VARCHAR(500) NOT NULL DEFAULT '', + gender TINYINT UNSIGNED NOT NULL DEFAULT 0, + birthday DATE NULL, + height_cm SMALLINT UNSIGNED NULL, + city_code VARCHAR(20) NOT NULL DEFAULT '', + city_name VARCHAR(50) NOT NULL DEFAULT '', + occupation VARCHAR(100) NOT NULL DEFAULT '', + education TINYINT UNSIGNED NOT NULL DEFAULT 0, + relationship_status TINYINT UNSIGNED NOT NULL DEFAULT 0, + bio VARCHAR(500) NOT NULL DEFAULT '', + profile_score SMALLINT UNSIGNED NOT NULL DEFAULT 0, + is_vip TINYINT UNSIGNED NOT NULL DEFAULT 0, + vip_level TINYINT UNSIGNED NOT NULL DEFAULT 0, + last_active_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_profiles_city_active (city_code, last_active_at), + CONSTRAINT fk_profiles_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_privacy_settings ( + user_id BIGINT UNSIGNED NOT NULL, + nearby_visible TINYINT(1) NOT NULL DEFAULT 1, + distance_visible TINYINT(1) NOT NULL DEFAULT 1, + online_visible TINYINT(1) NOT NULL DEFAULT 1, + last_active_visible TINYINT(1) NOT NULL DEFAULT 1, + allow_stranger_message TINYINT(1) NOT NULL DEFAULT 1, + allow_profile_visit_record TINYINT(1) NOT NULL DEFAULT 1, + allow_search TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + CONSTRAINT fk_privacy_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_devices ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + device_id VARCHAR(100) NOT NULL, + platform VARCHAR(20) NOT NULL, + device_model VARCHAR(100) NOT NULL DEFAULT '', + os_version VARCHAR(50) NOT NULL DEFAULT '', + app_version VARCHAR(30) NOT NULL DEFAULT '', + push_provider VARCHAR(30) NOT NULL DEFAULT '', + push_token VARCHAR(255) NOT NULL DEFAULT '', + last_ip VARCHAR(45) NOT NULL DEFAULT '', + last_active_at DATETIME(3) NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_device_user_device (user_id, device_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_sessions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + device_id VARCHAR(100) NOT NULL, + refresh_token_hash BINARY(32) NOT NULL, + expires_at DATETIME(3) NOT NULL, + last_active_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + revoked_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sessions_user (user_id, revoked_at), + UNIQUE KEY uk_sessions_refresh (refresh_token_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tags ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + category VARCHAR(30) NOT NULL, + name VARCHAR(50) NOT NULL, + icon VARCHAR(100) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_tags_category_name (category, name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_tags ( + user_id BIGINT UNSIGNED NOT NULL, + tag_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, tag_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + username VARCHAR(50) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + real_name VARCHAR(50) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + last_login_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_admin_username (username) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/002_social.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/002_social.sql new file mode 100644 index 0000000..1944c57 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/002_social.sql @@ -0,0 +1,65 @@ +CREATE TABLE IF NOT EXISTS user_follows ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + target_user_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_follow_pair (user_id, target_user_id), + KEY idx_follow_target (target_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_likes ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + target_user_id BIGINT UNSIGNED NOT NULL, + source VARCHAR(30) NOT NULL DEFAULT 'discover', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_like_pair (user_id, target_user_id), + KEY idx_like_target (target_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_matches ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user1_id BIGINT UNSIGNED NOT NULL, + user2_id BIGINT UNSIGNED NOT NULL, + matched_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + PRIMARY KEY (id), + UNIQUE KEY uk_match_pair (user1_id, user2_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_blocks ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + blocked_user_id BIGINT UNSIGNED NOT NULL, + reason VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_block_pair (user_id, blocked_user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS profile_visits ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + viewer_user_id BIGINT UNSIGNED NOT NULL, + target_user_id BIGINT UNSIGNED NOT NULL, + source VARCHAR(30) NOT NULL DEFAULT 'profile', + visited_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_visit_target_time (target_user_id, visited_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_location_states ( + user_id BIGINT UNSIGNED NOT NULL, + city_code VARCHAR(20) NOT NULL, + location_cell VARCHAR(32) NOT NULL DEFAULT '', + latitude DECIMAL(10,7) NULL, + longitude DECIMAL(10,7) NULL, + last_location_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + source VARCHAR(20) NOT NULL DEFAULT 'gps', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_location_city_time (city_code, last_location_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/003_feed.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/003_feed.sql new file mode 100644 index 0000000..28a9be2 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/003_feed.sql @@ -0,0 +1,72 @@ +CREATE TABLE IF NOT EXISTS media_assets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + owner_user_id BIGINT UNSIGNED NOT NULL, + media_type VARCHAR(20) NOT NULL, + storage_provider VARCHAR(20) NOT NULL DEFAULT 'local', + bucket VARCHAR(100) NOT NULL DEFAULT '', + object_key VARCHAR(500) NOT NULL DEFAULT '', + public_url VARCHAR(500) NOT NULL DEFAULT '', + mime_type VARCHAR(100) NOT NULL DEFAULT '', + file_size BIGINT UNSIGNED NOT NULL DEFAULT 0, + width INT UNSIGNED NULL, + height INT UNSIGNED NULL, + duration_ms INT UNSIGNED NULL, + moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 1, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS posts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + content VARCHAR(2000) NOT NULL DEFAULT '', + visibility TINYINT UNSIGNED NOT NULL DEFAULT 1, + city_code VARCHAR(20) NOT NULL DEFAULT '', + location_text VARCHAR(100) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 1, + like_count INT UNSIGNED NOT NULL DEFAULT 0, + comment_count INT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id), + KEY idx_posts_status_created (status, created_at), + KEY idx_posts_user_created (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS post_media ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + post_id BIGINT UNSIGNED NOT NULL, + media_id BIGINT UNSIGNED NULL, + media_url VARCHAR(500) NOT NULL DEFAULT '', + media_type VARCHAR(20) NOT NULL DEFAULT 'image', + sort_order INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY idx_post_media_post (post_id, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS post_likes ( + post_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (post_id, user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS post_comments ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + post_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + parent_comment_id BIGINT UNSIGNED NULL, + reply_user_id BIGINT UNSIGNED NULL, + content VARCHAR(1000) NOT NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id), + KEY idx_comments_post_created (post_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/004_im.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/004_im.sql new file mode 100644 index 0000000..63b090c --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/004_im.sql @@ -0,0 +1,68 @@ +CREATE TABLE IF NOT EXISTS im_conversations ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + conversation_type TINYINT UNSIGNED NOT NULL DEFAULT 1, + last_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + last_message_id BIGINT UNSIGNED NULL, + last_message_at DATETIME(3) NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_conversation_last (last_message_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_direct_conversations ( + conversation_id BIGINT UNSIGNED NOT NULL, + user1_id BIGINT UNSIGNED NOT NULL, + user2_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (conversation_id), + UNIQUE KEY uk_direct_pair (user1_id, user2_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_conversation_members ( + conversation_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + join_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + read_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + delivered_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + clear_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + pinned TINYINT(1) NOT NULL DEFAULT 0, + muted TINYINT(1) NOT NULL DEFAULT 0, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + joined_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (conversation_id, user_id), + KEY idx_member_user (user_id, updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_messages ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + conversation_id BIGINT UNSIGNED NOT NULL, + seq BIGINT UNSIGNED NOT NULL, + sender_id BIGINT UNSIGNED NOT NULL, + client_msg_id CHAR(26) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + message_type SMALLINT UNSIGNED NOT NULL, + body MEDIUMBLOB NOT NULL, + reply_to_message_id BIGINT UNSIGNED NULL, + moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 1, + recalled_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_conv_seq (conversation_id, seq), + UNIQUE KEY uk_sender_client_msg (sender_id, client_msg_id), + KEY idx_conv_created (conversation_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_user_sync_events ( + user_id BIGINT UNSIGNED NOT NULL, + event_seq BIGINT UNSIGNED NOT NULL, + event_type SMALLINT UNSIGNED NOT NULL, + conversation_id BIGINT UNSIGNED NOT NULL, + message_seq BIGINT UNSIGNED NOT NULL, + event_data MEDIUMBLOB NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, event_seq), + KEY idx_sync_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/005_membership.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/005_membership.sql new file mode 100644 index 0000000..ed854a3 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/005_membership.sql @@ -0,0 +1,80 @@ +CREATE TABLE IF NOT EXISTS membership_plans ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + code VARCHAR(30) NOT NULL, + name VARCHAR(50) NOT NULL, + level TINYINT UNSIGNED NOT NULL, + duration_days INT UNSIGNED NOT NULL, + price_cent INT UNSIGNED NOT NULL, + original_price_cent INT UNSIGNED NOT NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_plan_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS benefit_definitions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + benefit_key VARCHAR(50) NOT NULL, + name VARCHAR(100) NOT NULL, + value_type VARCHAR(20) NOT NULL, + description VARCHAR(255) NOT NULL DEFAULT '', + PRIMARY KEY (id), + UNIQUE KEY uk_benefit_key (benefit_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS plan_benefits ( + plan_id BIGINT UNSIGNED NOT NULL, + benefit_id BIGINT UNSIGNED NOT NULL, + benefit_value VARCHAR(255) NOT NULL, + PRIMARY KEY (plan_id, benefit_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS subscriptions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + plan_id BIGINT UNSIGNED NOT NULL, + source VARCHAR(30) NOT NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + started_at DATETIME(3) NOT NULL, + expires_at DATETIME(3) NOT NULL, + auto_renew TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sub_user_expire (user_id, expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_entitlements ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + benefit_key VARCHAR(50) NOT NULL, + benefit_value VARCHAR(255) NOT NULL, + source_type VARCHAR(30) NOT NULL, + source_id BIGINT UNSIGNED NOT NULL, + started_at DATETIME(3) NOT NULL, + expires_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_entitlement_user_key (user_id, benefit_key, expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS orders ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_no VARCHAR(40) NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + product_type VARCHAR(30) NOT NULL, + product_id BIGINT UNSIGNED NOT NULL, + amount_cent INT UNSIGNED NOT NULL, + currency CHAR(3) NOT NULL DEFAULT 'CNY', + status VARCHAR(20) NOT NULL DEFAULT 'CREATED', + channel VARCHAR(30) NOT NULL DEFAULT '', + paid_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_order_no (order_no), + KEY idx_order_user_created (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/006_moderation.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/006_moderation.sql new file mode 100644 index 0000000..887eec5 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/006_moderation.sql @@ -0,0 +1,72 @@ +CREATE TABLE IF NOT EXISTS reports ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + reporter_user_id BIGINT UNSIGNED NOT NULL, + target_type VARCHAR(30) NOT NULL, + target_id BIGINT UNSIGNED NOT NULL, + reason_code VARCHAR(50) NOT NULL, + description VARCHAR(1000) NOT NULL DEFAULT '', + evidence_json JSON NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + handled_by BIGINT UNSIGNED NULL, + handled_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_reports_status_created (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS moderation_tasks ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + target_type VARCHAR(30) NOT NULL, + target_id BIGINT UNSIGNED NOT NULL, + content_type VARCHAR(30) NOT NULL, + risk_score INT NOT NULL DEFAULT 0, + machine_result JSON NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + reviewer_id BIGINT UNSIGNED NULL, + review_result VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + reviewed_at DATETIME(3) NULL, + PRIMARY KEY (id), + KEY idx_moderation_status_created (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_risk_profiles ( + user_id BIGINT UNSIGNED NOT NULL, + risk_score INT NOT NULL DEFAULT 0, + risk_level TINYINT UNSIGNED NOT NULL DEFAULT 0, + message_score INT NOT NULL DEFAULT 0, + device_score INT NOT NULL DEFAULT 0, + report_score INT NOT NULL DEFAULT 0, + behavior_score INT NOT NULL DEFAULT 0, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_risk_level_score (risk_level, risk_score) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS risk_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + event_type VARCHAR(50) NOT NULL, + score_delta INT NOT NULL, + device_id VARCHAR(100) NOT NULL DEFAULT '', + ip VARCHAR(45) NOT NULL DEFAULT '', + metadata JSON NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_risk_event_user_created (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS notifications ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + type VARCHAR(30) NOT NULL, + title VARCHAR(100) NOT NULL, + content VARCHAR(1000) NOT NULL, + biz_type VARCHAR(30) NOT NULL DEFAULT '', + biz_id BIGINT UNSIGNED NULL, + read_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_notify_user_read_created (user_id, read_at, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/007_system.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/007_system.sql new file mode 100644 index 0000000..850e6f0 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/007_system.sql @@ -0,0 +1,70 @@ +CREATE TABLE IF NOT EXISTS system_configs ( + config_key VARCHAR(100) NOT NULL, + config_value TEXT NOT NULL, + value_type VARCHAR(20) NOT NULL DEFAULT 'string', + description VARCHAR(255) NOT NULL DEFAULT '', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (config_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS banners ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + title VARCHAR(100) NOT NULL, + image_url VARCHAR(500) NOT NULL, + link_url VARCHAR(500) NOT NULL DEFAULT '', + position VARCHAR(30) NOT NULL DEFAULT 'home', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + starts_at DATETIME(3) NULL, + ends_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS app_versions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + platform VARCHAR(20) NOT NULL, + version VARCHAR(30) NOT NULL, + build_number INT UNSIGNED NOT NULL, + force_update TINYINT(1) NOT NULL DEFAULT 0, + download_url VARCHAR(500) NOT NULL DEFAULT '', + release_notes VARCHAR(2000) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_version_platform_build (platform, build_number) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_audit_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + admin_user_id BIGINT UNSIGNED NOT NULL, + action VARCHAR(100) NOT NULL, + target_type VARCHAR(50) NOT NULL DEFAULT '', + target_id BIGINT UNSIGNED NULL, + request_data JSON NULL, + ip VARCHAR(45) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_audit_admin_created (admin_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('im.recall_seconds', '120', 'number', '消息撤回时间窗口'), + ('nearby.max_distance_km', '50', 'number', '附近的人最大距离'), + ('stranger.daily_limit', '10', 'number', '普通用户每日主动聊天人数') +ON DUPLICATE KEY UPDATE description = VALUES(description); + +INSERT INTO membership_plans (code, name, level, duration_days, price_cent, original_price_cent, status, sort_order) VALUES + ('VIP_1M', 'VIP 1个月', 1, 30, 2800, 4000, 1, 10), + ('VIP_3M', 'VIP 3个月', 1, 90, 6800, 9000, 1, 20), + ('SVIP_12M', 'SVIP 12个月', 2, 365, 22800, 36000, 1, 30) +ON DUPLICATE KEY UPDATE name = VALUES(name), price_cent = VALUES(price_cent), original_price_cent = VALUES(original_price_cent); + +INSERT INTO tags (category, name, icon, status, sort_order) VALUES + ('personality', '天秤座', '', 1, 10), + ('hobby', '摄影爱好者', '', 1, 20), + ('hobby', '旅行达人', '', 1, 30), + ('hobby', '电影', '', 1, 40), + ('hobby', '音乐', '', 1, 50) +ON DUPLICATE KEY UPDATE status = VALUES(status); + diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/008_integrations.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/008_integrations.sql new file mode 100644 index 0000000..e1d902e --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/008_integrations.sql @@ -0,0 +1,37 @@ +CREATE TABLE IF NOT EXISTS sms_verification_codes ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + phone_hash BINARY(32) NOT NULL, + scene VARCHAR(30) NOT NULL, + code_hash BINARY(32) NOT NULL, + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sms_phone_scene_created (phone_hash, scene, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('sms.enabled', 'true', 'boolean', '是否启用短信服务'), + ('sms.provider', 'debug', 'string', '短信提供商:debug 或 webhook'), + ('sms.sign_name', '星遇社交', 'string', '短信签名'), + ('sms.template_register', 'REGISTER', 'string', '注册验证码模板 ID'), + ('sms.template_login', 'LOGIN', 'string', '登录验证码模板 ID'), + ('sms.template_reset', 'RESET', 'string', '找回密码模板 ID'), + ('sms.webhook_url', '', 'string', '短信网关 Webhook 地址'), + ('sms.webhook_token', '', 'secret', '短信网关鉴权令牌'), + ('sms.debug_code', '123456', 'secret', '本地调试验证码'), + ('sms.expire_seconds', '300', 'number', '验证码有效期(秒)'), + ('payment.mode', 'sandbox', 'string', '支付模式:sandbox 或 live'), + ('payment.alipay.enabled', 'true', 'boolean', '是否启用支付宝'), + ('payment.alipay.app_id', '', 'string', '支付宝应用 APPID'), + ('payment.alipay.private_key', '', 'secret', '支付宝应用私钥'), + ('payment.alipay.public_key', '', 'secret', '支付宝公钥'), + ('payment.alipay.notify_url', '', 'string', '支付宝异步通知地址'), + ('payment.wechat.enabled', 'true', 'boolean', '是否启用微信支付'), + ('payment.wechat.app_id', '', 'string', '微信支付 AppID'), + ('payment.wechat.mch_id', '', 'string', '微信支付商户号'), + ('payment.wechat.api_v3_key', '', 'secret', '微信支付 APIv3 密钥'), + ('payment.wechat.private_key', '', 'secret', '微信支付商户私钥'), + ('payment.wechat.serial_no', '', 'string', '微信支付证书序列号'), + ('payment.wechat.notify_url', '', 'string', '微信支付回调地址') +ON DUPLICATE KEY UPDATE description = VALUES(description), value_type = VALUES(value_type); diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/009_repair_utf8_defaults.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/009_repair_utf8_defaults.sql new file mode 100644 index 0000000..d40b76f --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/009_repair_utf8_defaults.sql @@ -0,0 +1,6 @@ +-- PowerShell 5 may encode text piped to native executables as the active ANSI +-- code page. Repair the only user-visible non-ASCII integration default for +-- databases initialized by the earlier migration runner. +UPDATE system_configs +SET config_value = CONVERT(0xE6989FE98187E7A4BEE4BAA4 USING utf8mb4) +WHERE config_key = 'sms.sign_name' AND config_value = '????'; diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/010_admin_operations.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/010_admin_operations.sql new file mode 100644 index 0000000..4fea041 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/010_admin_operations.sql @@ -0,0 +1,64 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS user_verifications ( + user_id BIGINT UNSIGNED NOT NULL, + verification_type VARCHAR(30) NOT NULL DEFAULT 'real_name', + status VARCHAR(20) NOT NULL DEFAULT 'UNVERIFIED', + real_name VARCHAR(50) NOT NULL DEFAULT '', + document_mask VARCHAR(80) NOT NULL DEFAULT '', + remark VARCHAR(500) NOT NULL DEFAULT '', + reviewer_admin_id BIGINT UNSIGNED NULL, + submitted_at DATETIME(3) NULL, + reviewed_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_verification_status_updated (status, updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_sanctions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + sanction_type VARCHAR(30) NOT NULL, + reason VARCHAR(500) NOT NULL, + starts_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + expires_at DATETIME(3) NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + operator_admin_id BIGINT UNSIGNED NOT NULL, + revoked_by BIGINT UNSIGNED NULL, + revoked_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sanction_user_status_expire (user_id, status, expires_at), + KEY idx_sanction_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_security_controls ( + user_id BIGINT UNSIGNED NOT NULL, + token_version INT UNSIGNED NOT NULL DEFAULT 0, + force_logout_at DATETIME(3) NULL, + password_reset_at DATETIME(3) NULL, + last_operator_admin_id BIGINT UNSIGNED NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +SET @token_version_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_security_controls' AND COLUMN_NAME='token_version'); +SET @token_version_sql=IF(@token_version_exists=0,'ALTER TABLE user_security_controls ADD COLUMN token_version INT UNSIGNED NOT NULL DEFAULT 0 AFTER user_id','SELECT 1'); +PREPARE token_version_stmt FROM @token_version_sql; +EXECUTE token_version_stmt; +DEALLOCATE PREPARE token_version_stmt; + +INSERT INTO user_verifications (user_id, status) +SELECT u.id, 'UNVERIFIED' FROM users u +LEFT JOIN user_verifications v ON v.user_id=u.id +WHERE v.user_id IS NULL; + +INSERT INTO subscriptions (user_id, plan_id, source, status, started_at, expires_at) +SELECT p.user_id, mp.id, 'legacy_backfill', 1, NOW(3), DATE_ADD(NOW(3), INTERVAL mp.duration_days DAY) +FROM user_profiles p +JOIN membership_plans mp ON mp.level=p.vip_level + AND mp.duration_days=(SELECT MAX(mp2.duration_days) FROM membership_plans mp2 WHERE mp2.level=p.vip_level) +WHERE p.is_vip=1 AND p.vip_level>0 + AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.user_id=p.user_id AND s.status=1 AND s.expires_at>NOW(3)); diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/011_commerce_message_admin.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/011_commerce_message_admin.sql new file mode 100644 index 0000000..8d7fef8 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/011_commerce_message_admin.sql @@ -0,0 +1,25 @@ +SET NAMES utf8mb4; + +SET @plan_deleted_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='membership_plans' AND COLUMN_NAME='deleted_at'); +SET @plan_deleted_sql=IF(@plan_deleted_exists=0,'ALTER TABLE membership_plans ADD COLUMN deleted_at DATETIME(3) NULL AFTER updated_at','SELECT 1'); +PREPARE plan_deleted_stmt FROM @plan_deleted_sql; +EXECUTE plan_deleted_stmt; +DEALLOCATE PREPARE plan_deleted_stmt; + +SET @order_deleted_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='deleted_at'); +SET @order_deleted_sql=IF(@order_deleted_exists=0,'ALTER TABLE orders ADD COLUMN deleted_at DATETIME(3) NULL AFTER updated_at','SELECT 1'); +PREPARE order_deleted_stmt FROM @order_deleted_sql; +EXECUTE order_deleted_stmt; +DEALLOCATE PREPARE order_deleted_stmt; + +SET @plan_deleted_index_exists=(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='membership_plans' AND INDEX_NAME='idx_plan_deleted_sort'); +SET @plan_deleted_index_sql=IF(@plan_deleted_index_exists=0,'ALTER TABLE membership_plans ADD KEY idx_plan_deleted_sort (deleted_at,sort_order)','SELECT 1'); +PREPARE plan_deleted_index_stmt FROM @plan_deleted_index_sql; +EXECUTE plan_deleted_index_stmt; +DEALLOCATE PREPARE plan_deleted_index_stmt; + +SET @order_deleted_index_exists=(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND INDEX_NAME='idx_order_deleted_created'); +SET @order_deleted_index_sql=IF(@order_deleted_index_exists=0,'ALTER TABLE orders ADD KEY idx_order_deleted_created (deleted_at,created_at)','SELECT 1'); +PREPARE order_deleted_index_stmt FROM @order_deleted_index_sql; +EXECUTE order_deleted_index_stmt; +DEALLOCATE PREPARE order_deleted_index_stmt; diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/012_expand_client_message_id.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/012_expand_client_message_id.sql new file mode 100644 index 0000000..8010cc5 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/012_expand_client_message_id.sql @@ -0,0 +1,6 @@ +SET NAMES utf8mb4; + +-- Accept both the canonical 26-character client ID and UUID-style IDs from +-- older/cached clients while preserving sender-level idempotency. +ALTER TABLE im_messages + MODIFY COLUMN client_msg_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL; diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/013_security_hardening.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/013_security_hardening.sql new file mode 100644 index 0000000..543c563 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/013_security_hardening.sql @@ -0,0 +1,12 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS api_rate_limits ( + bucket_key BINARY(32) NOT NULL, + action_name VARCHAR(40) NOT NULL, + hits INT UNSIGNED NOT NULL DEFAULT 1, + expires_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (bucket_key), + KEY idx_rate_limit_expires (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/014_payment_gateway.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/014_payment_gateway.sql new file mode 100644 index 0000000..6a21487 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/014_payment_gateway.sql @@ -0,0 +1,61 @@ +SET NAMES utf8mb4; + +SET @provider_order_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='provider_order_no'); +SET @provider_order_sql=IF(@provider_order_exists=0,'ALTER TABLE orders ADD COLUMN provider_order_no VARCHAR(100) NOT NULL DEFAULT '''' AFTER channel','SELECT 1'); +PREPARE provider_order_stmt FROM @provider_order_sql; +EXECUTE provider_order_stmt; +DEALLOCATE PREPARE provider_order_stmt; + +SET @checkout_url_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='checkout_url'); +SET @checkout_url_sql=IF(@checkout_url_exists=0,'ALTER TABLE orders ADD COLUMN checkout_url VARCHAR(1000) NOT NULL DEFAULT '''' AFTER provider_order_no','SELECT 1'); +PREPARE checkout_url_stmt FROM @checkout_url_sql; +EXECUTE checkout_url_stmt; +DEALLOCATE PREPARE checkout_url_stmt; + +SET @payment_payload_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='payment_payload'); +SET @payment_payload_sql=IF(@payment_payload_exists=0,'ALTER TABLE orders ADD COLUMN payment_payload MEDIUMTEXT NULL AFTER checkout_url','SELECT 1'); +PREPARE payment_payload_stmt FROM @payment_payload_sql; +EXECUTE payment_payload_stmt; +DEALLOCATE PREPARE payment_payload_stmt; + +SET @paid_amount_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='paid_amount_cent'); +SET @paid_amount_sql=IF(@paid_amount_exists=0,'ALTER TABLE orders ADD COLUMN paid_amount_cent INT UNSIGNED NULL AFTER amount_cent','SELECT 1'); +PREPARE paid_amount_stmt FROM @paid_amount_sql; +EXECUTE paid_amount_stmt; +DEALLOCATE PREPARE paid_amount_stmt; + +SET @payment_notified_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='payment_notified_at'); +SET @payment_notified_sql=IF(@payment_notified_exists=0,'ALTER TABLE orders ADD COLUMN payment_notified_at DATETIME(3) NULL AFTER paid_at','SELECT 1'); +PREPARE payment_notified_stmt FROM @payment_notified_sql; +EXECUTE payment_notified_stmt; +DEALLOCATE PREPARE payment_notified_stmt; + +SET @provider_order_index_exists=(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND INDEX_NAME='idx_order_provider_no'); +SET @provider_order_index_sql=IF(@provider_order_index_exists=0,'ALTER TABLE orders ADD KEY idx_order_provider_no (provider_order_no)','SELECT 1'); +PREPARE provider_order_index_stmt FROM @provider_order_index_sql; +EXECUTE provider_order_index_stmt; +DEALLOCATE PREPARE provider_order_index_stmt; + +CREATE TABLE IF NOT EXISTS payment_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + event_id VARCHAR(100) NOT NULL, + order_no VARCHAR(40) NOT NULL, + channel VARCHAR(30) NOT NULL, + provider_order_no VARCHAR(100) NOT NULL DEFAULT '', + event_status VARCHAR(30) NOT NULL, + amount_cent INT UNSIGNED NOT NULL DEFAULT 0, + raw_payload MEDIUMTEXT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_payment_event (event_id), + KEY idx_payment_event_order (order_no,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key,config_value,value_type,description) VALUES + ('payment.gateway.create_url','','string','统一支付网关创建支付地址'), + ('payment.gateway.token','','secret','统一支付网关 Bearer Token'), + ('payment.gateway.notify_secret','','secret','支付通知 HMAC-SHA256 密钥'), + ('payment.gateway.notify_url','','string','本系统支付通知公网 HTTPS 地址'), + ('payment.gateway.return_url','','string','支付完成后的客户端返回地址'), + ('payment.gateway.timeout_seconds','10','number','支付网关请求超时秒数') +ON DUPLICATE KEY UPDATE description=VALUES(description),value_type=VALUES(value_type); diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/015_payment_refunds.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/015_payment_refunds.sql new file mode 100644 index 0000000..d5bfc53 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/015_payment_refunds.sql @@ -0,0 +1,5 @@ +SET NAMES utf8mb4; + +INSERT INTO system_configs (config_key,config_value,value_type,description) VALUES + ('payment.gateway.refund_url','','string','统一支付网关退款申请地址') +ON DUPLICATE KEY UPDATE description=VALUES(description),value_type=VALUES(value_type); diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/016_admin_password_security.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/016_admin_password_security.sql new file mode 100644 index 0000000..0f0ffc7 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/016_admin_password_security.sql @@ -0,0 +1,6 @@ +SET NAMES utf8mb4; + +ALTER TABLE admin_users + ADD COLUMN token_version INT UNSIGNED NOT NULL DEFAULT 0 AFTER status, + ADD COLUMN password_changed_at DATETIME(3) NULL AFTER last_login_at; + diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/017_sms_cloud_providers.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/017_sms_cloud_providers.sql new file mode 100644 index 0000000..cb1a9d9 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/017_sms_cloud_providers.sql @@ -0,0 +1,39 @@ +SET NAMES utf8mb4; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('sms.aliyun.endpoint', 'https://dysmsapi.aliyuncs.com', 'string', '阿里云短信 API 地址'), + ('sms.aliyun.access_key_id', '', 'secret', '阿里云 AccessKey ID'), + ('sms.aliyun.access_key_secret', '', 'secret', '阿里云 AccessKey Secret'), + ('sms.aliyun.sign_name', '', 'string', '阿里云短信签名'), + ('sms.aliyun.template_register', '', 'string', '阿里云注册模板 Code'), + ('sms.aliyun.template_login', '', 'string', '阿里云登录模板 Code'), + ('sms.aliyun.template_reset', '', 'string', '阿里云重置密码模板 Code'), + ('sms.aliyun.template_params', '{"code":"{{code}}"}', 'string', '阿里云模板变量 JSON'), + + ('sms.tencent.endpoint', 'https://sms.tencentcloudapi.com', 'string', '腾讯云短信 API 地址'), + ('sms.tencent.secret_id', '', 'secret', '腾讯云 SecretId'), + ('sms.tencent.secret_key', '', 'secret', '腾讯云 SecretKey'), + ('sms.tencent.sdk_app_id', '', 'string', '腾讯云短信 SdkAppId'), + ('sms.tencent.region', 'ap-guangzhou', 'string', '腾讯云短信地域'), + ('sms.tencent.sign_name', '', 'string', '腾讯云短信签名'), + ('sms.tencent.template_register', '', 'string', '腾讯云注册模板 ID'), + ('sms.tencent.template_login', '', 'string', '腾讯云登录模板 ID'), + ('sms.tencent.template_reset', '', 'string', '腾讯云重置密码模板 ID'), + ('sms.tencent.template_params', '["{{code}}"]', 'string', '腾讯云模板参数 JSON'), + + ('sms.huawei.endpoint', '', 'string', '华为云短信 APP 接入地址'), + ('sms.huawei.app_key', '', 'secret', '华为云短信 Application Key'), + ('sms.huawei.app_secret', '', 'secret', '华为云短信 Application Secret'), + ('sms.huawei.sender', '', 'string', '华为云短信签名通道号'), + ('sms.huawei.signature', '', 'string', '华为云短信签名名称'), + ('sms.huawei.template_register', '', 'string', '华为云注册模板 ID'), + ('sms.huawei.template_login', '', 'string', '华为云登录模板 ID'), + ('sms.huawei.template_reset', '', 'string', '华为云重置密码模板 ID'), + ('sms.huawei.template_params', '["{{code}}"]', 'string', '华为云模板参数 JSON'), + ('sms.huawei.status_callback', '', 'string', '华为云短信状态回调地址') +ON DUPLICATE KEY UPDATE description = VALUES(description), value_type = VALUES(value_type); + +UPDATE system_configs +SET description = '短信提供商:aliyun、tencent、huawei、webhook 或 debug' +WHERE config_key = 'sms.provider'; + diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/018_object_storage_providers.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/018_object_storage_providers.sql new file mode 100644 index 0000000..9174ca5 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/018_object_storage_providers.sql @@ -0,0 +1,39 @@ +SET NAMES utf8mb4; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('storage.provider', 'local', 'string', '文件存储提供商:local、aliyun_oss、tencent_cos、qiniu、huawei_obs 或 huawei_flexus'), + ('storage.object_prefix', 'media', 'string', '云端对象键前缀'), + + ('storage.local.directory', './uploads', 'string', '本地文件存储目录'), + ('storage.local.public_base_url', '', 'string', '本地文件公开访问地址'), + + ('storage.aliyun_oss.endpoint', 'https://oss-cn-hangzhou.aliyuncs.com', 'string', '阿里云 OSS Endpoint'), + ('storage.aliyun_oss.region', 'cn-hangzhou', 'string', '阿里云 OSS Region'), + ('storage.aliyun_oss.bucket', '', 'string', '阿里云 OSS Bucket'), + ('storage.aliyun_oss.access_key_id', '', 'secret', '阿里云 OSS AccessKey ID'), + ('storage.aliyun_oss.access_key_secret', '', 'secret', '阿里云 OSS AccessKey Secret'), + ('storage.aliyun_oss.public_base_url', '', 'string', '阿里云 OSS 文件访问域名'), + + ('storage.tencent_cos.endpoint', '', 'string', '腾讯云 COS Bucket URL'), + ('storage.tencent_cos.bucket', '', 'string', '腾讯云 COS Bucket'), + ('storage.tencent_cos.secret_id', '', 'secret', '腾讯云 COS SecretId'), + ('storage.tencent_cos.secret_key', '', 'secret', '腾讯云 COS SecretKey'), + ('storage.tencent_cos.public_base_url', '', 'string', '腾讯云 COS 文件访问域名'), + + ('storage.qiniu.bucket', '', 'string', '七牛云 Kodo 空间名称'), + ('storage.qiniu.access_key', '', 'secret', '七牛云 AccessKey'), + ('storage.qiniu.secret_key', '', 'secret', '七牛云 SecretKey'), + ('storage.qiniu.public_base_url', '', 'string', '七牛云文件访问域名'), + + ('storage.huawei_obs.endpoint', 'https://obs.cn-north-4.myhuaweicloud.com', 'string', '华为云 OBS Endpoint'), + ('storage.huawei_obs.bucket', '', 'string', '华为云 OBS Bucket'), + ('storage.huawei_obs.access_key', '', 'secret', '华为云 OBS Access Key'), + ('storage.huawei_obs.secret_key', '', 'secret', '华为云 OBS Secret Key'), + ('storage.huawei_obs.public_base_url', '', 'string', '华为云 OBS 文件访问域名'), + + ('storage.huawei_flexus.endpoint', '', 'string', '华为云 Flexus 对象存储 Endpoint'), + ('storage.huawei_flexus.bucket', '', 'string', '华为云 Flexus 对象存储 Bucket'), + ('storage.huawei_flexus.access_key', '', 'secret', '华为云 Flexus 对象存储 Access Key'), + ('storage.huawei_flexus.secret_key', '', 'secret', '华为云 Flexus 对象存储 Secret Key'), + ('storage.huawei_flexus.public_base_url', '', 'string', '华为云 Flexus 对象存储文件访问域名') +ON DUPLICATE KEY UPDATE description = VALUES(description), value_type = VALUES(value_type); diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/019_daily_active_chat_limit.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/019_daily_active_chat_limit.sql new file mode 100644 index 0000000..24efbb6 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/019_daily_active_chat_limit.sql @@ -0,0 +1,34 @@ +SET @daily_chat_limit_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='membership_plans' AND COLUMN_NAME='daily_active_chat_limit'); +SET @daily_chat_limit_sql=IF(@daily_chat_limit_exists=0,'ALTER TABLE membership_plans ADD COLUMN daily_active_chat_limit INT UNSIGNED NOT NULL DEFAULT 20 AFTER duration_days','SELECT 1'); +PREPARE daily_chat_limit_stmt FROM @daily_chat_limit_sql; +EXECUTE daily_chat_limit_stmt; +DEALLOCATE PREPARE daily_chat_limit_stmt; + +UPDATE membership_plans +SET daily_active_chat_limit=CASE WHEN level>=2 THEN 100 ELSE 20 END +WHERE daily_active_chat_limit=20; + +INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES + ('membership.free_daily_active_chat_limit','5','integer','普通用户每日可主动聊天的不同用户数,0 表示不限制') +ON DUPLICATE KEY UPDATE description=VALUES(description); + +CREATE TABLE IF NOT EXISTS im_daily_active_chat_usage ( + user_id BIGINT UNSIGNED NOT NULL, + usage_date DATE NOT NULL, + used_count INT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id,usage_date), + KEY idx_daily_chat_usage_date (usage_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_daily_active_chat_targets ( + user_id BIGINT UNSIGNED NOT NULL, + target_user_id BIGINT UNSIGNED NOT NULL, + usage_date DATE NOT NULL, + conversation_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id,target_user_id,usage_date), + KEY idx_daily_chat_target_date (target_user_id,usage_date), + KEY idx_daily_chat_conversation (conversation_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/020_admin_oauth_login.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/020_admin_oauth_login.sql new file mode 100644 index 0000000..e93527f --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/020_admin_oauth_login.sql @@ -0,0 +1,87 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS admin_oauth_identities ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + provider VARCHAR(20) NOT NULL, + subject VARCHAR(191) NOT NULL, + admin_user_id BIGINT UNSIGNED NOT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + display_name VARCHAR(100) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + last_login_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_admin_oauth_provider_subject (provider, subject), + UNIQUE KEY uk_admin_oauth_user_provider (admin_user_id, provider), + CONSTRAINT fk_admin_oauth_identity_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_oauth_states ( + state_hash BINARY(32) NOT NULL, + provider VARCHAR(20) NOT NULL, + code_verifier VARCHAR(128) NOT NULL DEFAULT '', + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (state_hash), + KEY idx_admin_oauth_state_expiry (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_oauth_login_codes ( + code_hash BINARY(32) NOT NULL, + provider VARCHAR(20) NOT NULL, + subject VARCHAR(191) NOT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + display_name VARCHAR(100) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + admin_user_id BIGINT UNSIGNED NULL, + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (code_hash), + KEY idx_admin_oauth_code_expiry (expires_at), + KEY idx_admin_oauth_code_admin (admin_user_id), + CONSTRAINT fk_admin_oauth_code_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('oauth.admin.frontend_callback_url', 'http://localhost:5560/auth/social-callback', 'string', '第三方登录完成后跳转的管理端页面;生产环境必须使用 HTTPS'), + + ('oauth.wechat.enabled', 'false', 'boolean', '启用微信扫码登录'), + ('oauth.wechat.client_id', '', 'string', '微信开放平台网站应用 AppID'), + ('oauth.wechat.client_secret', '', 'secret', '微信开放平台网站应用 AppSecret'), + ('oauth.wechat.authorization_url', 'https://open.weixin.qq.com/connect/qrconnect', 'string', '微信登录授权地址'), + ('oauth.wechat.token_url', 'https://api.weixin.qq.com/sns/oauth2/access_token', 'string', '微信登录令牌地址'), + ('oauth.wechat.userinfo_url', 'https://api.weixin.qq.com/sns/userinfo', 'string', '微信用户信息地址'), + ('oauth.wechat.scope', 'snsapi_login', 'string', '微信网站应用登录授权范围'), + ('oauth.wechat.redirect_uri', 'http://127.0.0.1:8888/admin/v1/auth/oauth/callback', 'string', '微信开放平台登记的授权回调地址;生产环境必须使用 HTTPS'), + + ('oauth.qq.enabled', 'false', 'boolean', '启用 QQ 登录'), + ('oauth.qq.client_id', '', 'string', 'QQ 互联应用 AppID'), + ('oauth.qq.client_secret', '', 'secret', 'QQ 互联应用 AppKey'), + ('oauth.qq.authorization_url', 'https://graph.qq.com/oauth2.0/authorize', 'string', 'QQ 登录授权地址'), + ('oauth.qq.token_url', 'https://graph.qq.com/oauth2.0/token', 'string', 'QQ 登录令牌地址'), + ('oauth.qq.openid_url', 'https://graph.qq.com/oauth2.0/me', 'string', 'QQ OpenID 查询地址'), + ('oauth.qq.userinfo_url', 'https://graph.qq.com/user/get_user_info', 'string', 'QQ 用户信息地址'), + ('oauth.qq.scope', 'get_user_info', 'string', 'QQ 登录授权范围'), + ('oauth.qq.redirect_uri', 'http://127.0.0.1:8888/admin/v1/auth/oauth/callback', 'string', 'QQ 互联登记的授权回调地址;生产环境必须使用 HTTPS'), + + ('oauth.github.enabled', 'false', 'boolean', '启用 GitHub 登录'), + ('oauth.github.client_id', '', 'string', 'GitHub OAuth App Client ID'), + ('oauth.github.client_secret', '', 'secret', 'GitHub OAuth App Client Secret'), + ('oauth.github.authorization_url', 'https://github.com/login/oauth/authorize', 'string', 'GitHub OAuth 授权地址'), + ('oauth.github.token_url', 'https://github.com/login/oauth/access_token', 'string', 'GitHub OAuth 令牌地址'), + ('oauth.github.userinfo_url', 'https://api.github.com/user', 'string', 'GitHub 当前用户信息地址'), + ('oauth.github.scope', 'read:user user:email', 'string', 'GitHub 登录最小授权范围'), + ('oauth.github.redirect_uri', 'http://127.0.0.1:8888/admin/v1/auth/oauth/callback', 'string', 'GitHub OAuth App 登记的 callback URL;生产环境必须使用 HTTPS'), + + ('oauth.google.enabled', 'false', 'boolean', '启用 Google 登录'), + ('oauth.google.client_id', '', 'string', 'Google OAuth 2.0 Client ID'), + ('oauth.google.client_secret', '', 'secret', 'Google OAuth 2.0 Client Secret'), + ('oauth.google.authorization_url', 'https://accounts.google.com/o/oauth2/v2/auth', 'string', 'Google OAuth 授权地址'), + ('oauth.google.token_url', 'https://oauth2.googleapis.com/token', 'string', 'Google OAuth 令牌地址'), + ('oauth.google.userinfo_url', 'https://openidconnect.googleapis.com/v1/userinfo', 'string', 'Google OpenID Connect UserInfo 地址'), + ('oauth.google.scope', 'openid profile email', 'string', 'Google 登录授权范围'), + ('oauth.google.redirect_uri', 'http://127.0.0.1:8888/admin/v1/auth/oauth/callback', 'string', 'Google Cloud Console 登记的 redirect URI;生产环境必须使用 HTTPS') +ON DUPLICATE KEY UPDATE description = VALUES(description); diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/021_user_oauth_login.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/021_user_oauth_login.sql new file mode 100644 index 0000000..03e36c1 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/021_user_oauth_login.sql @@ -0,0 +1,54 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS user_oauth_identities ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + provider VARCHAR(20) NOT NULL, + subject VARCHAR(191) NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + display_name VARCHAR(100) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + last_login_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_user_oauth_provider_subject (provider, subject), + UNIQUE KEY uk_user_oauth_user_provider (user_id, provider), + CONSTRAINT fk_user_oauth_identity_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_oauth_states ( + state_hash BINARY(32) NOT NULL, + provider VARCHAR(20) NOT NULL, + code_verifier VARCHAR(128) NOT NULL DEFAULT '', + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (state_hash), + KEY idx_user_oauth_state_expiry (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_oauth_login_codes ( + code_hash BINARY(32) NOT NULL, + provider VARCHAR(20) NOT NULL, + subject VARCHAR(191) NOT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + display_name VARCHAR(100) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + user_id BIGINT UNSIGNED NULL, + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (code_hash), + KEY idx_user_oauth_code_expiry (expires_at), + KEY idx_user_oauth_code_user (user_id), + CONSTRAINT fk_user_oauth_code_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('oauth.user.frontend_callback_url', 'http://localhost:5174/#/pages/auth/oauth-callback', 'string', '第三方登录完成后跳转的 uni-app H5 页面;生产环境必须使用 HTTPS'), + ('oauth.user.wechat.enabled', 'false', 'boolean', '在客户端启用微信登录'), + ('oauth.user.qq.enabled', 'false', 'boolean', '在客户端启用 QQ 登录'), + ('oauth.user.github.enabled', 'false', 'boolean', '在客户端启用 GitHub 登录'), + ('oauth.user.google.enabled', 'false', 'boolean', '在客户端启用 Google 登录') +ON DUPLICATE KEY UPDATE description = VALUES(description); diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/022_admin_sessions.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/022_admin_sessions.sql new file mode 100644 index 0000000..0696e28 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/022_admin_sessions.sql @@ -0,0 +1,17 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS admin_sessions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + admin_user_id BIGINT UNSIGNED NOT NULL, + refresh_token_hash BINARY(32) NOT NULL, + user_agent_hash BINARY(32) NOT NULL, + ip VARCHAR(45) NOT NULL DEFAULT '', + expires_at DATETIME(3) NOT NULL, + last_active_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + revoked_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_admin_sessions_refresh (refresh_token_hash), + KEY idx_admin_sessions_user (admin_user_id, revoked_at, expires_at), + CONSTRAINT fk_admin_sessions_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/023_admin_rbac.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/023_admin_rbac.sql new file mode 100644 index 0000000..47da153 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/023_admin_rbac.sql @@ -0,0 +1,77 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS admin_roles ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + role_code VARCHAR(50) NOT NULL, + role_name VARCHAR(80) NOT NULL, + description VARCHAR(255) NOT NULL DEFAULT '', + built_in TINYINT(1) NOT NULL DEFAULT 0, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_admin_roles_code (role_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_permissions ( + permission_code VARCHAR(80) NOT NULL, + permission_name VARCHAR(100) NOT NULL, + permission_group VARCHAR(50) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + PRIMARY KEY (permission_code), + KEY idx_admin_permissions_group (permission_group, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_role_permissions ( + role_id BIGINT UNSIGNED NOT NULL, + permission_code VARCHAR(80) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (role_id, permission_code), + CONSTRAINT fk_admin_role_permissions_role FOREIGN KEY (role_id) REFERENCES admin_roles(id) ON DELETE CASCADE, + CONSTRAINT fk_admin_role_permissions_permission FOREIGN KEY (permission_code) REFERENCES admin_permissions(permission_code) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_user_roles ( + admin_user_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (admin_user_id, role_id), + KEY idx_admin_user_roles_role (role_id), + CONSTRAINT fk_admin_user_roles_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) ON DELETE CASCADE, + CONSTRAINT fk_admin_user_roles_role FOREIGN KEY (role_id) REFERENCES admin_roles(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO admin_permissions (permission_code, permission_name, permission_group, sort_order) VALUES + ('dashboard:view', '查看运营概览', 'dashboard', 10), + ('users:view', '查看用户', 'users', 20), + ('users:manage', '编辑用户资料和会员', 'users', 21), + ('users:security', '重置密码和强制下线', 'users', 22), + ('verification:manage', '审核用户认证', 'users', 23), + ('violations:manage', '处理违规和账号处罚', 'users', 24), + ('content:view', '查看动态内容', 'content', 30), + ('content:manage', '删除和处置动态', 'content', 31), + ('messages:view', '查看聊天消息记录', 'content', 32), + ('reports:handle', '处理举报', 'risk', 40), + ('risk:view', '查看风险信息', 'risk', 41), + ('membership:manage', '管理会员套餐', 'commerce', 50), + ('orders:view', '查看订单', 'commerce', 51), + ('orders:manage', '变更订单和退款', 'commerce', 52), + ('system:manage', '管理系统、集成和管理员', 'system', 60) +ON DUPLICATE KEY UPDATE + permission_name=VALUES(permission_name), + permission_group=VALUES(permission_group), + sort_order=VALUES(sort_order); + +INSERT INTO admin_roles (role_code, role_name, description, built_in, status) +VALUES ('super_admin', '超级管理员', '拥有平台全部权限;内置角色不可删除', 1, 1) +ON DUPLICATE KEY UPDATE role_name=VALUES(role_name), description=VALUES(description), built_in=1, status=1; + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_code) +SELECT r.id, p.permission_code +FROM admin_roles r CROSS JOIN admin_permissions p +WHERE r.role_code='super_admin'; + +-- Existing administrators keep their current capabilities after RBAC is enabled. +INSERT IGNORE INTO admin_user_roles (admin_user_id, role_id) +SELECT a.id, r.id +FROM admin_users a JOIN admin_roles r ON r.role_code='super_admin'; diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/024_user_verification_submission.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/024_user_verification_submission.sql new file mode 100644 index 0000000..08a9e44 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/024_user_verification_submission.sql @@ -0,0 +1,11 @@ +SET NAMES utf8mb4; + +ALTER TABLE user_verifications + ADD COLUMN document_hash BINARY(32) NULL AFTER document_mask, + ADD COLUMN evidence_json TEXT NULL AFTER document_hash, + ADD KEY idx_user_verifications_document (document_hash); + +UPDATE user_verifications SET evidence_json='[]' WHERE evidence_json IS NULL OR evidence_json=''; + +ALTER TABLE user_verifications + MODIFY COLUMN evidence_json TEXT NOT NULL; diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/025_client_productization.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/025_client_productization.sql new file mode 100644 index 0000000..183194f --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/025_client_productization.sql @@ -0,0 +1,140 @@ +SET NAMES utf8mb4; + +ALTER TABLE user_privacy_settings + ADD COLUMN invisible_visit TINYINT(1) NOT NULL DEFAULT 0 AFTER allow_profile_visit_record; + +ALTER TABLE membership_plans + ADD COLUMN daily_like_limit INT UNSIGNED NOT NULL DEFAULT 20 AFTER daily_active_chat_limit, + ADD COLUMN can_view_visitors TINYINT(1) NOT NULL DEFAULT 0 AFTER daily_like_limit, + ADD COLUMN can_invisible_visit TINYINT(1) NOT NULL DEFAULT 0 AFTER can_view_visitors, + ADD COLUMN recommendation_weight INT UNSIGNED NOT NULL DEFAULT 0 AFTER can_invisible_visit; + +UPDATE membership_plans +SET daily_like_limit=CASE WHEN level>=2 THEN 0 WHEN level=1 THEN 100 ELSE 20 END, + can_view_visitors=IF(level>=1,1,0), + can_invisible_visit=IF(level>=1,1,0), + recommendation_weight=CASE WHEN level>=2 THEN 30 WHEN level=1 THEN 15 ELSE 0 END; + +ALTER TABLE user_verifications + DROP PRIMARY KEY, + ADD PRIMARY KEY (user_id, verification_type); + +CREATE TABLE IF NOT EXISTS user_daily_like_usage ( + user_id BIGINT UNSIGNED NOT NULL, + usage_date DATE NOT NULL, + used_count INT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, usage_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_daily_like_targets ( + user_id BIGINT UNSIGNED NOT NULL, + target_type VARCHAR(20) NOT NULL, + target_id BIGINT UNSIGNED NOT NULL, + usage_date DATE NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, target_type, target_id, usage_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_notification_settings ( + user_id BIGINT UNSIGNED NOT NULL, + im_enabled TINYINT(1) NOT NULL DEFAULT 1, + interaction_enabled TINYINT(1) NOT NULL DEFAULT 1, + system_enabled TINYINT(1) NOT NULL DEFAULT 1, + sound_enabled TINYINT(1) NOT NULL DEFAULT 1, + vibration_enabled TINYINT(1) NOT NULL DEFAULT 1, + quiet_start VARCHAR(5) NOT NULL DEFAULT '', + quiet_end VARCHAR(5) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_push_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + device_id VARCHAR(100) NOT NULL, + provider VARCHAR(30) NOT NULL, + push_token VARCHAR(500) NOT NULL, + platform VARCHAR(20) NOT NULL DEFAULT '', + app_version VARCHAR(30) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + last_active_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_push_device_provider (user_id, device_id, provider), + KEY idx_push_user_status (user_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_feedback ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + category VARCHAR(30) NOT NULL, + content VARCHAR(2000) NOT NULL, + contact VARCHAR(100) NOT NULL DEFAULT '', + evidence_json JSON NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + reply_content VARCHAR(2000) NOT NULL DEFAULT '', + handled_by BIGINT UNSIGNED NULL, + handled_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_feedback_user_created (user_id, created_at), + KEY idx_feedback_status_created (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_account_closures ( + user_id BIGINT UNSIGNED NOT NULL, + reason VARCHAR(500) NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + requested_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + execute_after DATETIME(3) NOT NULL, + cancelled_at DATETIME(3) NULL, + completed_at DATETIME(3) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_closure_status_execute (status, execute_after) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_consents ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + agreement_type VARCHAR(30) NOT NULL, + agreement_version VARCHAR(30) NOT NULL, + accepted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + ip VARCHAR(45) NOT NULL DEFAULT '', + device_id VARCHAR(100) NOT NULL DEFAULT '', + PRIMARY KEY (id), + UNIQUE KEY uk_user_consent_version (user_id, agreement_type, agreement_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE orders + ADD COLUMN refund_reason VARCHAR(500) NOT NULL DEFAULT '' AFTER payment_notified_at, + ADD COLUMN refund_requested_at DATETIME(3) NULL AFTER refund_reason; + +INSERT INTO user_notification_settings(user_id) +SELECT id FROM users +ON DUPLICATE KEY UPDATE user_id=VALUES(user_id); + +INSERT INTO system_configs (config_key,config_value,value_type,description) VALUES + ('membership.free_daily_like_limit','20','number','普通用户每日点赞次数'), + ('account.cancellation_cooling_days','7','number','账号注销冷静期天数'), + ('legal.user_agreement_version','1.0','string','用户协议版本'), + ('legal.privacy_policy_version','1.0','string','隐私政策版本'), + ('legal.operator_name','','string','平台运营主体全称(上线前必填)'), + ('legal.contact','','string','隐私与客服联系方式(上线前必填)'), + ('legal.effective_date','','string','协议生效日期,格式 YYYY-MM-DD'), + ('legal.user_agreement_url','','string','完整用户协议 HTTPS 地址'), + ('legal.privacy_policy_url','','string','完整隐私政策 HTTPS 地址'), + ('app.maintenance.enabled','false','boolean','客户端维护模式'), + ('app.maintenance.message','系统维护中,请稍后再试','string','客户端维护提示'), + ('app.features.nearby','true','boolean','附近功能开关'), + ('app.features.feed','true','boolean','动态功能开关'), + ('app.features.membership','true','boolean','会员功能开关'), + ('app.features.im','true','boolean','即时通讯功能开关'), + ('app.min_version.android','1.0.0','string','Android 最低版本'), + ('app.min_version.ios','1.0.0','string','iOS 最低版本'), + ('app.min_version.h5','1.0.0','string','H5 最低版本') +ON DUPLICATE KEY UPDATE description=VALUES(description),value_type=VALUES(value_type); diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/026_report_operations.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/026_report_operations.sql new file mode 100644 index 0000000..2a01644 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/026_report_operations.sql @@ -0,0 +1,17 @@ +ALTER TABLE reports + ADD COLUMN action_type VARCHAR(30) NOT NULL DEFAULT '' AFTER status, + ADD COLUMN handle_remark VARCHAR(1000) NOT NULL DEFAULT '' AFTER action_type; + +CREATE INDEX idx_reports_target ON reports(target_type, target_id, created_at); + +ALTER TABLE im_messages + ADD COLUMN admin_removed_at DATETIME(3) NULL AFTER recalled_at, + ADD COLUMN admin_removed_by BIGINT UNSIGNED NULL AFTER admin_removed_at, + ADD COLUMN admin_remove_reason VARCHAR(500) NOT NULL DEFAULT '' AFTER admin_removed_by; + +INSERT INTO admin_permissions(permission_code,permission_name,permission_group,sort_order) +VALUES('messages:manage','下架及恢复违规聊天消息','content',33) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),permission_group=VALUES(permission_group),sort_order=VALUES(sort_order); + +INSERT IGNORE INTO admin_role_permissions(role_id,permission_code) +SELECT id,'messages:manage' FROM admin_roles WHERE role_code='super_admin'; diff --git a/im/.deploy/application-20260831-120612/rollback-backend/migrations/028_test_users.sql b/im/.deploy/application-20260831-120612/rollback-backend/migrations/028_test_users.sql new file mode 100644 index 0000000..e9e6321 --- /dev/null +++ b/im/.deploy/application-20260831-120612/rollback-backend/migrations/028_test_users.sql @@ -0,0 +1,7 @@ +SET NAMES utf8mb4; + +-- Registration does not set these fields: genuine users remain non-test. +ALTER TABLE users + ADD COLUMN is_test TINYINT(1) NOT NULL DEFAULT 0, + ADD COLUMN test_batch VARCHAR(64) NOT NULL DEFAULT '', + ADD KEY idx_users_test_batch (is_test, test_batch, id); diff --git a/im/.deploy/application-20260831-120612/verify_remote.py b/im/.deploy/application-20260831-120612/verify_remote.py new file mode 100644 index 0000000..41b88c8 --- /dev/null +++ b/im/.deploy/application-20260831-120612/verify_remote.py @@ -0,0 +1,44 @@ +import hashlib +import json +import os +import sys +from pathlib import Path + +sys.path.insert(0, '/www/server/xingyu-im/ops') +from baota_publish_test_users import BT, WEB, database, digest, env_values, query + +backup = Path('/www/backup/xingyu-application-20260831-121140') +state = json.loads((backup / 'result.json').read_text()) +expected = state['backendSha256'] +assert digest(BT / 'xingyu-api') == expected +assert str((WEB / 'admin').resolve()) == state['admin'] +assert str((WEB / 'app').resolve()) == state['h5'] +running = [] +for proc in Path('/proc').iterdir(): + if not proc.name.isdigit(): + continue + try: + if os.readlink(str(proc / 'exe')) == str(BT / 'xingyu-api'): + assert digest(proc / 'exe') == expected + running.append(int(proc.name)) + except (FileNotFoundError, PermissionError): + pass +assert len(running) == 1, 'expected one process running the released executable' +conn, _ = database(env_values()) +try: + migrations = dict(query(conn, 'SELECT version,checksum FROM schema_migrations')) + for name in state['migrationsApplied']: + assert migrations[name] == digest(BT / 'migrations' / name) + counts = {} + for table in ['users', 'admin_users', 'user_sessions', 'orders', 'posts']: + counts[table] = query(conn, 'SELECT COUNT(*) FROM ' + table)[0][0] + cos_count = query(conn, "SELECT COUNT(*) FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.is_test=1 AND p.avatar_url LIKE 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/media/test-users/cn-adults-20260831-v1/%%'")[0][0] + assert cos_count == 100 + permission_count = query(conn, "SELECT COUNT(*) FROM admin_role_permissions rp JOIN admin_roles r ON r.id=rp.role_id WHERE r.role_code='super_admin' AND rp.permission_code='users:create'")[0][0] + assert permission_count == 1 + assert backup.stat().st_mode & 0o777 == 0o700 + assert (backup / 'im.sql.gz').stat().st_size > 0 + assert (backup / 'im.sql.gz').stat().st_mode & 0o777 == 0o600 + print(json.dumps({'release': state, 'runningPids': running, 'migrationsRecorded': len(migrations), 'recordCounts': counts, 'testCosAvatars': cos_count, 'superAdminCreateUserPermission': True, 'backupProtected': True})) +finally: + conn.close() diff --git a/im/.deploy/audit_nearby_distance_prod.py b/im/.deploy/audit_nearby_distance_prod.py new file mode 100644 index 0000000..9d87969 --- /dev/null +++ b/im/.deploy/audit_nearby_distance_prod.py @@ -0,0 +1,11 @@ +import hashlib,json,os,sys +from pathlib import Path +sys.path.insert(0,'/www/server/xingyu-im/ops') +from baota_publish_test_users import database,env_values,query +binary=Path('/www/server/xingyu-im/bt/xingyu-api') +conn,_=database(env_values()) +try: + row=query(conn,"""SELECT COUNT(*),SUM(privacy.distance_visible=1),SUM(location.user_id IS NOT NULL),SUM(location.source='fixture') FROM users user JOIN user_privacy_settings privacy ON privacy.user_id=user.id LEFT JOIN user_location_states location ON location.user_id=user.id WHERE user.is_test=1 AND user.test_batch=%s AND user.deleted_at IS NULL""",('cn-adults-20260831-v1',))[0] + result={'backendSha256':hashlib.sha256(binary.read_bytes()).hexdigest(),'appRelease':os.path.realpath('/www/wwwroot/im.bchongw.com/app'),'coverage':[int(x) for x in row]} + print(json.dumps(result)) +finally: conn.close() diff --git a/im/.deploy/audit_nearby_layout_prod.py b/im/.deploy/audit_nearby_layout_prod.py new file mode 100644 index 0000000..51b01a0 --- /dev/null +++ b/im/.deploy/audit_nearby_layout_prod.py @@ -0,0 +1,2 @@ +import json,os +print(json.dumps({'appRelease':os.path.realpath('/www/wwwroot/im.bchongw.com/app')})) diff --git a/im/.deploy/audit_unread_open_prod.py b/im/.deploy/audit_unread_open_prod.py new file mode 100644 index 0000000..51b01a0 --- /dev/null +++ b/im/.deploy/audit_unread_open_prod.py @@ -0,0 +1,2 @@ +import json,os +print(json.dumps({'appRelease':os.path.realpath('/www/wwwroot/im.bchongw.com/app')})) diff --git a/im/.deploy/avatar-preflight.py b/im/.deploy/avatar-preflight.py new file mode 100644 index 0000000..1746ed0 --- /dev/null +++ b/im/.deploy/avatar-preflight.py @@ -0,0 +1,26 @@ +import hashlib +import json +import os +import shutil +import sys +import urllib.request +from pathlib import Path +sys.path.insert(0, '/www/server/xingyu-im/ops') +from baota_publish_test_users import BT, WEB, database, digest, env_values, query +conn, _ = database(env_values()) +try: + counts = {table: query(conn, 'SELECT COUNT(*) FROM ' + table)[0][0] for table in ['users','user_profiles','media_assets','admin_users','im_messages']} + migrations = dict(query(conn, 'SELECT version,checksum FROM schema_migrations')) + release_sources = {str(path.parent): digest(path) for path in Path('/www/server/xingyu-im/releases').glob('*/backend-source.zip')} + avatars = query(conn, 'SELECT user_id,avatar_url FROM user_profiles ORDER BY user_id') + configs = query(conn, 'SELECT config_key,config_value FROM system_configs ORDER BY config_key') + pids=[] + for proc in Path('/proc').iterdir(): + if proc.name.isdigit(): + try: + if os.readlink(str(proc/'exe')) == str(BT/'xingyu-api'): pids.append(int(proc.name)) + except (FileNotFoundError,PermissionError): pass + with urllib.request.urlopen('http://127.0.0.1:18888/healthz', timeout=10) as response: + health_status=response.status + print(json.dumps({'backendSha256':digest(BT/'xingyu-api'),'pids':pids,'health':health_status,'frontend':{name:str((WEB/name).resolve()) for name in ['admin','app']},'sourceArchives':release_sources,'migrations':migrations,'counts':counts,'avatarsSha256':hashlib.sha256(repr(avatars).encode()).hexdigest(),'configSha256':hashlib.sha256(repr(configs).encode()).hexdigest(),'storageProvider':query(conn,"SELECT config_value FROM system_configs WHERE config_key='storage.provider'")[0][0],'freeDiskBytes':shutil.disk_usage('/www').free})) +finally: conn.close() diff --git a/im/.deploy/avatars-20260831-175508/live-result.json b/im/.deploy/avatars-20260831-175508/live-result.json new file mode 100644 index 0000000..3f642fb --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/live-result.json @@ -0,0 +1,54 @@ +{ + "stage": "/www/server/xingyu-im/releases/avatars-20260831-180014", + "backup": "/www/backup/xingyu-avatars-20260831-180014", + "archiveSha256": "28901eeaa88355886cf74899b4e3390cb0baeb5275c1126024f884b34731b404", + "previousBackendSha256": "812ceedad7a7714ad9d04558946370d5665b0a2a9231b0e1282cb65def9f03ab", + "backendSha256": "b7eeb926d063855e4271815872ba8c90e226554a3824d48e0995479a671e546e", + "previousH5": "/www/wwwroot/xingyu-h5/releases/application-20260831-121140", + "h5": "/www/wwwroot/xingyu-h5/releases/avatars-20260831-180014", + "adminUnchanged": "/www/wwwroot/xingyu-admin/releases/application-20260831-121140", + "changedBackendSourceFiles": [ + "backend/docs/avatar-thumbnails.md", + "backend/go.mod", + "backend/go.sum", + "backend/internal/app/avatar_images.go", + "backend/internal/app/avatar_images_test.go", + "backend/internal/app/media.go", + "backend/internal/app/media_batch.go", + "backend/internal/app/media_batch_test.go", + "backend/internal/app/media_upload_test.go" + ], + "before": { + "configValuesSha256": "ee6bd8451ebddb53e68d35d759eed85e54531dd62ed83ee74f86b46364b0f845", + "testAvatarsSha256": "6355419750cdcaa3d39c2afe776134f5010d7c0762e10fa80b4a0649fbd88134", + "testUsers": 100 + }, + "databaseMigrationsApplied": [], + "businessDataWrites": false, + "linuxAvatarTests": true, + "runningPids": [ + 9373 + ], + "publicAssetsVerified": [ + "/app/assets/AvatarImage.vue_vue_type_script_setup_true_lang.DAwyfq3U.js", + "/app/assets/index-BkEGutx5.js", + "/app/assets/index-D6nTj_fj.css", + "/app/assets/pages-chat-index.rAfd1Ei7.js", + "/app/assets/pages-edit-profile-index.DmPschJb.js", + "/app/assets/pages-home-index.Dgk8DhW8.js", + "/app/assets/pages-messages-index.ZWkkHAxX.js", + "/app/assets/uni.db7a9edc.css", + "/app/static/favicon.svg" + ], + "healthAndAuthChecks": true, + "preservedConfigurationAndTestAvatars": true, + "verificationCounts": { + "users": 100, + "user_profiles": 100, + "media_assets": 0, + "admin_users": 1, + "im_messages": 5, + "schema_migrations": 29 + }, + "verifiedRunningBinary": true +} \ No newline at end of file diff --git a/im/.deploy/avatars-20260831-175508/package-result.json b/im/.deploy/avatars-20260831-175508/package-result.json new file mode 100644 index 0000000..bcb3d7d --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/package-result.json @@ -0,0 +1,18 @@ +{ + "archiveSha256": "28901eeaa88355886cf74899b4e3390cb0baeb5275c1126024f884b34731b404", + "archiveBytes": 38837143, + "backendSha256": "b7eeb926d063855e4271815872ba8c90e226554a3824d48e0995479a671e546e", + "backendSourceSha256": "bc6c1bafc57d3b7e772e7823d89485d6dbce6cc11ff33fc74a01878665a73f10", + "h5Sha256": "00facb91404f06147e9d1f85d872fcc8019878e3a09e45dee8c156db74665214", + "backendSourceChanges": [ + "backend/docs/avatar-thumbnails.md", + "backend/go.mod", + "backend/go.sum", + "backend/internal/app/avatar_images.go", + "backend/internal/app/avatar_images_test.go", + "backend/internal/app/media.go", + "backend/internal/app/media_batch.go", + "backend/internal/app/media_batch_test.go", + "backend/internal/app/media_upload_test.go" + ] +} \ No newline at end of file diff --git a/im/.deploy/avatars-20260831-175508/payload/avatar-check.test b/im/.deploy/avatars-20260831-175508/payload/avatar-check.test new file mode 100644 index 0000000..cb9276c Binary files /dev/null and b/im/.deploy/avatars-20260831-175508/payload/avatar-check.test differ diff --git a/im/.deploy/avatars-20260831-175508/payload/backend-source.zip b/im/.deploy/avatars-20260831-175508/payload/backend-source.zip new file mode 100644 index 0000000..51fa1db Binary files /dev/null and b/im/.deploy/avatars-20260831-175508/payload/backend-source.zip differ diff --git a/im/.deploy/avatars-20260831-175508/payload/baota_publish_avatars.py b/im/.deploy/avatars-20260831-175508/payload/baota_publish_avatars.py new file mode 100644 index 0000000..20b50d2 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/payload/baota_publish_avatars.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Publish reviewed avatar processing and H5 assets without changing user data.""" +import fcntl +import gzip +import hashlib +import json +import os +import pwd +import re +import shutil +import subprocess +import sys +import time +import urllib.error +import urllib.request +import zipfile +from pathlib import Path + +sys.path.insert(0, '/www/server/xingyu-im/ops') +from baota_publish_test_users import BT, WEB, DOMAIN, database, digest, env_values, extract, fetch, health, query, restart, switch + +OLD = '812ceedad7a7714ad9d04558946370d5665b0a2a9231b0e1282cb65def9f03ab' +BASE = Path('/www/server/xingyu-im/releases/im-unread-20260831-124713') +ALLOWED = {'backend/go.mod', 'backend/go.sum', 'backend/internal/app/media.go', + 'backend/internal/app/avatar_images.go', 'backend/internal/app/avatar_images_test.go', + 'backend/internal/app/media_batch.go', 'backend/internal/app/media_batch_test.go', + 'backend/internal/app/media_upload_test.go', 'backend/docs/avatar-thumbnails.md'} + + +def source_hashes(archive): + with zipfile.ZipFile(str(archive)) as package: + result = {} + for item in package.infolist(): + if item.is_dir(): + continue + name = item.filename.replace('\\', '/') + assert name not in result and not name.startswith('/') and '..' not in Path(name).parts + result[name] = hashlib.sha256(package.read(item)).hexdigest() + return result + + +def running(expected): + pids = [] + for proc in Path('/proc').iterdir(): + if not proc.name.isdigit(): + continue + try: + if os.readlink(str(proc / 'exe')) == str(BT / 'xingyu-api'): + assert digest(proc / 'exe') == expected + pids.append(int(proc.name)) + except (FileNotFoundError, PermissionError): + pass + assert len(pids) == 1 + return pids + + +def marker(conn): + configs = query(conn, 'SELECT config_key,config_value FROM system_configs ORDER BY config_key') + profiles = query(conn, 'SELECT p.user_id,p.avatar_url FROM user_profiles p JOIN users u ON u.id=p.user_id WHERE u.is_test=1 ORDER BY p.user_id') + return {'configValuesSha256': hashlib.sha256(repr(configs).encode()).hexdigest(), + 'testAvatarsSha256': hashlib.sha256(repr(profiles).encode()).hexdigest(), 'testUsers': len(profiles)} + + +def replace_binary(source, stamp): + target = BT / ('xingyu-api.avatars-' + stamp) + assert not target.exists() + shutil.copy2(str(source), str(target)) + www = pwd.getpwnam('www') + os.chown(str(target), www.pw_uid, www.pw_gid) + os.chmod(str(target), 0o750) + os.replace(str(target), str(BT / 'xingyu-api')) + + +def prepare_h5(stage, stamp): + entry = WEB / 'app' + previous = entry.resolve() + releases = Path('/www/wwwroot/xingyu-h5/releases') + assert entry.is_symlink() and previous.parent == releases + release = releases / ('avatars-' + stamp) + extract(stage / 'h5.zip', release) + assert '/app/assets/' in (release / 'index.html').read_text() + scripts = '\n'.join(p.read_text() for p in (release / 'assets').glob('*.js')) + assert all(value in scripts for value in ['uploadAvatar', '-av1', '-thumb.jpg', 'https://im.bchongw.com']) + for source in (previous / 'assets').rglob('*'): + if source.is_file(): + target = release / source.relative_to(previous) + if not target.exists(): + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(source), str(target)) + www = pwd.getpwnam('www') + for parent, _, files in os.walk(str(release)): + os.chown(parent, www.pw_uid, www.pw_gid) + os.chmod(parent, 0o755) + for name in files: + path = os.path.join(parent, name) + os.chown(path, www.pw_uid, www.pw_gid) + os.chmod(path, 0o644) + return entry, previous, release + + +def public_checks(stage): + verified = [] + with zipfile.ZipFile(str(stage / 'h5.zip')) as package: + index = package.read('index.html') + status, headers, body = fetch('/app/') + assert status == 200 and body == index + assert 'no-store' in ','.join(headers.get_all('Cache-Control', [])) + paths = set(re.findall(r'(?:src|href)="(/app/[^"?#]+)', index.decode())) + for name in package.namelist(): + if name.startswith(('assets/pages-edit-profile-', 'assets/pages-home-', 'assets/pages-chat-', 'assets/pages-messages-', 'assets/AvatarImage')) and name.endswith(('.js', '.css')): + paths.add('/app/' + name) + paths.add('/app/static/favicon.svg') + for path in sorted(paths): + if path.endswith(('.js', '.css', '.svg')): + status, _, body = fetch(path) + assert status == 200 and body == package.read(path[len('/app/'):]), path + verified.append(path) + assert fetch('/admin/')[0] == 200 + for path, method in [('/api/v1/media/upload', 'POST'), ('/api/v1/im/conversations', 'GET'), ('/admin/v1/users', 'GET'), ('/ws', 'GET')]: + try: + with urllib.request.urlopen(urllib.request.Request(DOMAIN + path, method=method), timeout=20): + raise RuntimeError('private endpoint became public') + except urllib.error.HTTPError as error: + assert error.code == 401 + health() + return verified + + +def publish(archive, expected): + assert os.geteuid() == 0 and re.fullmatch('[a-f0-9]{64}', expected) + archive = Path(archive).resolve() + assert archive.parent == Path('/tmp') and digest(archive) == expected + lock = open('/www/server/xingyu-im/ops/application-release.lock', 'a') + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + assert digest(BT / 'xingyu-api') == OLD, 'production changed; re-audit required' + running(OLD) + health() + subprocess.run(['/www/server/nginx/sbin/nginx', '-t'], check=True) + stamp = time.strftime('%Y%m%d-%H%M%S') + stage = Path('/www/server/xingyu-im/releases/avatars-' + stamp) + extract(archive, stage) + os.chmod(str(stage), 0o700) + manifest = json.loads((stage / 'manifest.json').read_text()) + for name, sha in manifest['files'].items(): + path = (stage / name).resolve() + assert stage in path.parents and path.is_file() and digest(path) == sha + old_source = source_hashes(BASE / 'backend-source.zip') + new_source = source_hashes(stage / 'backend-source.zip') + changed = {name for name in set(old_source) | set(new_source) if old_source.get(name) != new_source.get(name)} + assert changed == ALLOWED, 'unreviewed source changes: ' + repr(changed) + for name in ['xingyu-api', 'avatar-check.test']: + assert (stage / name).read_bytes()[:4] == b'\x7fELF' + probe = stage / 'avatar-check.test' + os.chmod(str(probe), 0o700) + # These exact tests use temporary local files and in-memory database/storage + # fixtures. They do not authenticate as a user or touch production records. + cases = 'TestAvatarVariantsDimensionsAndSource|TestAvatarPhoneEXIFOrientation|TestAvatarSmallTransparentImageIsNotEnlarged|TestAvatarRejectsInvalidAndExcessivePixels|TestAvatarGIFAndWebP|TestAvatarUploadStoresAndServesAllVariants|TestAvatarUploadFinalizationFailureCleansEveryObject|TestOrdinaryMediaUploadPreservesOriginal|TestInvalidAvatarUploadCreatesNoRecords|TestMediaVariantUploadCleanup|TestLocalUploadCollisionPreservesExistingObject' + subprocess.run([str(probe), '-test.run=^(' + cases + ')$', '-test.v'], cwd=str(stage), check=True, timeout=90) + print('LINUX_AVATAR_TESTS_PASSED', flush=True) + conn, options = database(env_values()) + try: + migrations = dict(query(conn, 'SELECT version,checksum FROM schema_migrations')) + assert {name: sha for name, sha in new_source.items() if name.startswith('backend/migrations/')} == {'backend/migrations/' + name: sha for name, sha in migrations.items()} + # Original binary rollback is safe for current COS objects; local + # storage would additionally need the new av1 static filename rule. + assert query(conn, "SELECT config_value FROM system_configs WHERE config_key='storage.provider'")[0][0] == 'tencent_cos' + before = marker(conn) + admin = str((WEB / 'admin').resolve()) + backup = Path('/www/backup/xingyu-avatars-' + stamp) + backup.mkdir(mode=0o700) + shutil.copy2(str(BT / 'xingyu-api'), str(backup / 'xingyu-api-original')) + dump = subprocess.Popen(['/opt/mysql-8.4.11/bin/mysqldump', '-h', '127.0.0.1', '-P', '3307', '-u', options['user'], '--single-transaction', '--no-tablespaces', '--set-gtid-purged=OFF', '--routines', '--triggers', 'im'], env=dict(os.environ, MYSQL_PWD=options['password']), stdout=subprocess.PIPE, stderr=subprocess.PIPE) + with gzip.open(str(backup / 'im.sql.gz'), 'wb') as output: + shutil.copyfileobj(dump.stdout, output) + dump.communicate() + assert dump.returncode == 0, 'database backup failed' + os.chmod(str(backup / 'im.sql.gz'), 0o600) + with gzip.open(str(backup / 'im.sql.gz'), 'rb') as check: + assert b'MySQL dump' in check.read(4096) + while check.read(1024 * 1024): + pass + entry, previous, release = prepare_h5(stage, stamp) + state = {'stage': str(stage), 'backup': str(backup), 'archiveSha256': expected, + 'previousBackendSha256': OLD, 'backendSha256': manifest['files']['xingyu-api'], + 'previousH5': str(previous), 'h5': str(release), 'adminUnchanged': admin, + 'changedBackendSourceFiles': sorted(changed), 'before': before, + 'databaseMigrationsApplied': [], 'businessDataWrites': False, 'linuxAvatarTests': True} + (backup / 'before.json').write_text(json.dumps(state, indent=2)) + print('BACKUP_VERIFIED=' + str(backup), flush=True) + binary_changed = switched = False + try: + replace_binary(stage / 'xingyu-api', stamp) + binary_changed = True + print('BACKEND_RESTARTING', flush=True) + restart() + state['runningPids'] = running(state['backendSha256']) + switch(entry, release, stamp) + switched = True + state['publicAssetsVerified'] = public_checks(stage) + assert marker(conn) == before, 'configuration or test avatars changed during release' + assert dict(query(conn, 'SELECT version,checksum FROM schema_migrations')) == migrations + assert str((WEB / 'admin').resolve()) == admin + state['healthAndAuthChecks'] = True + state['preservedConfigurationAndTestAvatars'] = True + (backup / 'result.json').write_text(json.dumps(state, indent=2)) + print('PUBLISHED=' + json.dumps(state), flush=True) + except Exception: + if switched: + switch(entry, previous, stamp + '-rollback') + if binary_changed: + replace_binary(backup / 'xingyu-api-original', stamp + '-rollback') + restart() + running(OLD) + print('ROLLED_BACK_APPLICATION; database untouched; backup=' + str(backup), flush=True) + raise + finally: + conn.close() + + +if __name__ == '__main__': + publish(*sys.argv[1:]) diff --git a/im/.deploy/avatars-20260831-175508/payload/h5.zip b/im/.deploy/avatars-20260831-175508/payload/h5.zip new file mode 100644 index 0000000..b49fa61 Binary files /dev/null and b/im/.deploy/avatars-20260831-175508/payload/h5.zip differ diff --git a/im/.deploy/avatars-20260831-175508/payload/manifest.json b/im/.deploy/avatars-20260831-175508/payload/manifest.json new file mode 100644 index 0000000..7912043 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/payload/manifest.json @@ -0,0 +1,25 @@ +{ + "files": { + "avatar-check.test": "a259037044553bcfa4bb65a1aca97852d19c89595ebb3e59a2987ca7408176aa", + "backend-source.zip": "bc6c1bafc57d3b7e772e7823d89485d6dbce6cc11ff33fc74a01878665a73f10", + "baota_publish_avatars.py": "d47ed5ed58f6d3045e25c7053bc3e29d22362b0faa2c57c1f9b2d4156fa3a37b", + "h5.zip": "00facb91404f06147e9d1f85d872fcc8019878e3a09e45dee8c156db74665214", + "mobile-source.zip": "d4a872dbcd4ed403a72c979a3128343d20459e828e2bd2715377a5e7a85f07ec", + "xingyu-api": "b7eeb926d063855e4271815872ba8c90e226554a3824d48e0995479a671e546e" + }, + "backendSourceChanges": [ + "backend/docs/avatar-thumbnails.md", + "backend/go.mod", + "backend/go.sum", + "backend/internal/app/avatar_images.go", + "backend/internal/app/avatar_images_test.go", + "backend/internal/app/media.go", + "backend/internal/app/media_batch.go", + "backend/internal/app/media_batch_test.go", + "backend/internal/app/media_upload_test.go" + ], + "backendTests": "passed", + "mobileRegressionTests": 49, + "typeCheck": "passed", + "h5Build": "passed" +} \ No newline at end of file diff --git a/im/.deploy/avatars-20260831-175508/payload/mobile-source.zip b/im/.deploy/avatars-20260831-175508/payload/mobile-source.zip new file mode 100644 index 0000000..5ccf110 Binary files /dev/null and b/im/.deploy/avatars-20260831-175508/payload/mobile-source.zip differ diff --git a/im/.deploy/avatars-20260831-175508/payload/xingyu-api b/im/.deploy/avatars-20260831-175508/payload/xingyu-api new file mode 100644 index 0000000..7e36a21 Binary files /dev/null and b/im/.deploy/avatars-20260831-175508/payload/xingyu-api differ diff --git a/im/.deploy/avatars-20260831-175508/public-verification.json b/im/.deploy/avatars-20260831-175508/public-verification.json new file mode 100644 index 0000000..b04a0a0 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/public-verification.json @@ -0,0 +1,11 @@ +{ + "tlsVerified": true, + "h5FileHashesVerified": 84, + "checks": { + "/healthz": 200, + "/admin/": 200, + "/api/v1/media/upload": 401, + "/api/v1/im/conversations": 401, + "/ws": 401 + } +} \ No newline at end of file diff --git a/im/.deploy/avatars-20260831-175508/release-local.json b/im/.deploy/avatars-20260831-175508/release-local.json new file mode 100644 index 0000000..37a5f82 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/release-local.json @@ -0,0 +1,15 @@ +{ + "expectedBackendSha256": "812ceedad7a7714ad9d04558946370d5665b0a2a9231b0e1282cb65def9f03ab", + "baseSourceSha256": "39ddf5b9aaa600a21d4c720edcc4475d4805a92eb1cef6a0d8e5c20f1f20b1f4", + "allowedBackendChanges": [ + "backend/go.mod", + "backend/go.sum", + "backend/internal/app/media.go", + "backend/internal/app/avatar_images.go", + "backend/internal/app/avatar_images_test.go", + "backend/internal/app/media_batch.go", + "backend/internal/app/media_batch_test.go", + "backend/internal/app/media_upload_test.go", + "backend/docs/avatar-thumbnails.md" + ] +} \ No newline at end of file diff --git a/im/.deploy/avatars-20260831-175508/release.sha256 b/im/.deploy/avatars-20260831-175508/release.sha256 new file mode 100644 index 0000000..c67dfac --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/release.sha256 @@ -0,0 +1 @@ +28901eeaa88355886cf74899b4e3390cb0baeb5275c1126024f884b34731b404 diff --git a/im/.deploy/avatars-20260831-175508/release.zip b/im/.deploy/avatars-20260831-175508/release.zip new file mode 100644 index 0000000..180c57a Binary files /dev/null and b/im/.deploy/avatars-20260831-175508/release.zip differ diff --git a/im/.deploy/avatars-20260831-175508/source/backend/cmd/server/main.go b/im/.deploy/avatars-20260831-175508/source/backend/cmd/server/main.go new file mode 100644 index 0000000..3c528ee --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/cmd/server/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "log" + + "github.com/example/xingyu/internal/app" +) + +func main() { + service, err := app.New(app.LoadConfig()) + if err != nil { + log.Fatal(err) + } + defer service.Close() + if err := service.Seed(); err != nil { + log.Fatal(err) + } + service.Run() +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/docs/admin-create-user.md b/im/.deploy/avatars-20260831-175508/source/backend/docs/admin-create-user.md new file mode 100644 index 0000000..1292631 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/docs/admin-create-user.md @@ -0,0 +1,26 @@ +# 管理端新增客户端用户 + +入口:管理端 → 用户管理 → 新增用户。必填中国大陆手机号、昵称和初始密码;性别、城市、简介可选。创建后列表自动定位到新用户,可继续编辑资料。 + +- 账号默认为正常、正式用户、普通会员状态、未认证;创建时不发短信、不创建登录会话。 +- 初始密码必填,但不限制长度或字符组合,纯数字、纯字母、中文等均可。账号可在 App/H5 使用手机号和初始密码登录;请通过安全渠道交付密码,并提醒用户登录后修改。 +- 手机号通过唯一索引防止重复(包括已停用或尚未释放手机号的账号),使用 AES-GCM 加密保存;密码以 bcrypt 哈希保存,超过 72 字节的密码使用 Argon2id,兼容已有账号。通用 HTTP 请求大小限制仍然生效。 +- 账号、资料、隐私设置、通知设置和审计日志在同一事务内创建;任一写入失败会回滚。 + +## 接口与权限 + +`POST /admin/v1/users`,必须携带有效管理员令牌,并具备 `users:create` 权限。请求字段:`phone`、`password`、`nickname`,可选 `gender`(0/1/2)、`city`、`bio`。不接受设置会员、认证、测试标记或账号状态的额外字段。 + +成功返回通用响应中的 `data: { id, publicId }`,不返回密码或用户令牌。参数错误返回 HTTP 400,手机号重复返回 409,权限不足返回 403,数据库错误返回 500。 + +审计动作是 `create_user`,包含操作管理员、目标用户、星遇号、昵称、来源及操作 IP,不保存初始密码或完整手机号。 + +## 发布 + +1. 使用项目现有迁移流程执行 `backend/migrations/029_admin_create_users.sql`(此前的迁移必须已执行)。该迁移新增权限,并仅默认授予内置超级管理员角色。 +2. 发布更新后的后端和管理端构建。管理端构建命令:在 `admin` 目录执行 `pnpm --filter @vben/web-ele build`。 +3. 管理员重新登录以刷新权限;其他运营角色可在“管理员与权限”中按需勾选“新增客户端用户”,并保留“查看用户”以访问用户列表。 + +## 验证 + +`go test ./internal/app -run TestAdminCreateUser -count=1` 覆盖真实路由的认证和权限、参数校验、重复手机号保护、敏感数据保护、事务回滚、列表查询及密码登录。测试使用内存 SQL 适配器,不接触线上账号;真实数据库迁移和部署后仍需验收。 diff --git a/im/.deploy/avatars-20260831-175508/source/backend/docs/avatar-thumbnails.md b/im/.deploy/avatars-20260831-175508/source/backend/docs/avatar-thumbnails.md new file mode 100644 index 0000000..fc25eb8 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/docs/avatar-thumbnails.md @@ -0,0 +1,51 @@ +# 头像上传与缩略图 + +头像专用上传在服务端生成静态图片,不需要开通 COS、OSS 等厂商的图片处理功能,也不改变管理员选择的存储厂商。当前规则只应用于新上传的头像,不下载、覆盖或批量替换已有用户头像。 + +## 接口 + +`POST /api/v1/media/upload`,使用原有用户认证,`multipart/form-data`: + +- `file`:头像文件。 +- `purpose=avatar`:启用头像处理。不传此字段时,普通图片、封面、语音和证据上传仍原样保存。 + +成功响应的 `data` 保留已有字段,同时提供两个可选地址: + +```json +{ + "id": 42, + "name": "42-1788189400000-av1.jpg", + "url": "https://cdn.example.com/media/2026/08/42-1788189400000-av1.jpg", + "thumbnailUrl": "https://cdn.example.com/media/2026/08/42-1788189400000-av1-thumb.jpg", + "originalUrl": "https://cdn.example.com/media/2026/08/42-1788189400000-av1-original.png", + "provider": "tencent_cos", + "objectKey": "media/2026/08/42-1788189400000-av1.jpg" +} +``` + +- `url`:最长边不超过 **640px** 的 JPEG 展示图,质量 82。将此 URL 保存到用户资料的 `avatar` 字段。旧客户端和管理端也能正常显示这张较小的图片。 +- `thumbnailUrl`:最长边不超过 **256px** 的 JPEG 缩略图,质量 78。uni-app 的列表、聊天、评论、资料头像等小尺寸位置加载此图;首页推荐卡片使用 640px 展示图。 +- `originalUrl`:本次上传的原始文件副本,字节不变;若客户端选择图片时已压缩,则这里保留的是压缩后实际上传的文件,并非相册原始文件。 +- 未传 `purpose` 的普通媒体上传,`thumbnailUrl`、`originalUrl` 为空字符串,`url` 仍为原文件。 + +图片等比例缩小,不放大、不在服务器裁剪人脸。客户端继续通过 `aspectFill` 显示。JPEG 手机照片先按 EXIF 方向校正,再缩放;展示图和缩略图不含原 EXIF/GPS。透明区域合成白底。GIF 使用首帧,WebP 使用解码器支持的静态图片;不支持的动画 WebP 或损坏文件返回明确错误,不悄悄退回高清原图。 + +## 可靠性与兼容性 + +- 延用 16 MiB 文件限制,头像另限制为最多 2400 万像素、单边最多 16384px,并限制同时解码的数量,避免小服务器被大图耗尽内存。处理繁忙时返回 503 和 `Retry-After: 2`。 +- 原图、缩略图和展示图全部上传成功后,才激活媒体记录、返回成功。中途失败或数据库收尾失败会尝试删除已写入的对象和未完成的记录;清理使用独立超时,不受客户端取消影响。本地同名文件不会被覆盖或误删。 +- `media_assets` 记录主展示图及其实际 MIME/大小,不需要数据库迁移;原图和缩略图使用同一前缀的配套文件。以后实现物理删除或存储生命周期规则时,需要同时处理三种文件。 +- `-av1` 文件名是已生成配套缩略图的标识。客户端只改写这些普通公开 URL,旧头像、第三方 OAuth 头像、临时本地文件和带查询参数的签名 URL 保持原样。缩略图失败时仅回退一次到展示图,防止重复请求。 +- 头像组件附带 `lazy-load`,由支持此属性的平台延迟加载;主要性能收益来自真正减小图片像素和文件体积,不依赖所有端都支持懒加载。 +- 编辑资料时禁止上传未结束就保存,避免把旧头像误存回服务器。令牌刷新重试会保留 `purpose=avatar`。 +- 旧客户端继续调用普通上传接口时行为不变。需要新版客户端的头像上传调用,才会触发生成;已存在的头像不会自动变小,需要重新上传或另行安排有备份的批量转换。 + +## 验证与发布 + +运行 `go test ./...`、`pnpm test:avatars`、`pnpm test:native-components` 和 `pnpm type-check`。回归覆盖尺寸、透明图、方向信息、GIF/WebP、损坏或超大图片、存储失败清理、本地同名文件保护、实际 HTTP 上传/读取,以及前端回退和令牌刷新。 + +项目里的一个头像样本从 1,853,171 字节生成 46,180 字节展示图与 11,131 字节缩略图,缩略图缩小约 99.4%;比例取决于原图内容和格式,不能视作所有图片的固定压缩率。 + +上线需要更新后端和 H5,原生 App 需重新构建/安装。这份改动本身不执行生产部署,不改线上账号或历史文件。旧后端不认识新增的本地 `-av1` 文件名;如果使用本地存储,回滚服务时必须保留新的静态文件名规则,或由静态服务器继续提供这些文件。 + +实现参考:[imaging 缩放和 EXIF 方向处理](https://github.com/disintegration/imaging)、[uni-app image 平台属性](https://uniapp.dcloud.net.cn/component/image.html)。 diff --git a/im/.deploy/avatars-20260831-175508/source/backend/docs/password-policy.md b/im/.deploy/avatars-20260831-175508/source/backend/docs/password-policy.md new file mode 100644 index 0000000..838f89f --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/docs/password-policy.md @@ -0,0 +1,17 @@ +# 密码输入规则 + +管理端修改密码、管理员创建/重置密码、管理端新增客户端用户,以及客户端注册、找回和修改密码统一取消长度和字符组合要求。允许单个字符、纯数字、纯字母、中文等,不展示密码强度条,也不限制新密码必须不同于旧密码。 + +保留密码必填、两次输入一致、旧密码核验,以及登录鉴权、短信验证码、接口权限和请求频率限制。修改密码后原有会话继续失效。编辑管理员资料时密码留空仍表示不修改密码。 + +## 存储兼容 + +原有 bcrypt 密码不需要迁移。72 字节以内的密码继续使用 bcrypt;更长的密码使用带随机盐的 Argon2id(19 MiB 内存、2 次迭代、1 个线程),不截断输入。验证时根据哈希前缀选择算法,只接受已支持的参数,避免损坏数据触发不受控的内存分配。已有 `password_hash VARCHAR(255)` 字段可容纳这两种格式。 + +使用 Argon2id 是为了避免移除输入上限后触发 bcrypt 的技术限制,参数参考 [OWASP 密码存储建议](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html),实现使用 [Go Argon2 包](https://pkg.go.dev/golang.org/x/crypto/argon2)。通用 HTTP 请求体积限制保留,不属于密码格式规则。 + +## 验证与发布 + +后端测试覆盖简单密码及长密码的创建和登录、管理员修改密码、旧密码验证、旧会话撤销失败时回滚、已有 bcrypt 密码兼容、长密码尾部不同不能登录、随机盐和损坏哈希处理。 + +本次修改无需新增数据库迁移。需同步发布后端和管理端;App 用户需安装新包,H5 更新构建。长密码一旦保存为 Argon2id,回退后端到旧版本会导致这些账号无法登录,回退时应保留新哈希的验证支持。 diff --git a/im/.deploy/avatars-20260831-175508/source/backend/docs/social.api b/im/.deploy/avatars-20260831-175508/source/backend/docs/social.api new file mode 100644 index 0000000..01db08a --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/docs/social.api @@ -0,0 +1,182 @@ +syntax = "v1" + +info ( + title: "星遇社交平台 API" + desc: "账户、发现、动态、会员与 IM REST 接口定义" + author: "Xingyu" + version: "1.0" +) + +type LoginRequest { Phone string `json:"phone"`; Password string `json:"password"`; DeviceID string `json:"deviceId"` } +type LoginResponse { AccessToken string `json:"accessToken"`; RefreshToken string `json:"refreshToken"`; ExpiresIn int64 `json:"expiresIn"`; UserID int64 `json:"userId"` } +type CreatePostRequest { Content string `json:"content"`; Media []string `json:"media"`; Location string `json:"location"`; Visibility int `json:"visibility"` } +type SMSRequest { Phone string `json:"phone"`; Scene string `json:"scene"` } +type ResetPasswordRequest { Phone string `json:"phone"`; Code string `json:"code"`; Password string `json:"password"` } +type SendMessageRequest { ClientMsgID string `json:"clientMsgId"`; Type int `json:"type"`; Content map[string]string `json:"content"` } +type UserVerificationRequest { Type string `json:"type"`; RealName string `json:"realName"`; DocumentNumber string `json:"documentNumber"`; Evidence []string `json:"evidence"` } +type AdminProfileRequest { Phone string `json:"phone"`; Nickname string `json:"nickname"`; Avatar string `json:"avatar"`; Cover string `json:"cover"`; Gender int `json:"gender"`; Birthday string `json:"birthday"`; Height int `json:"height"`; CityCode string `json:"cityCode"`; City string `json:"city"`; Occupation string `json:"occupation"`; Bio string `json:"bio"` } +type VerificationRequest { Type string `json:"type"`; Status string `json:"status"`; RealName string `json:"realName"`; DocumentMask string `json:"documentMask"`; Remark string `json:"remark"` } +type MembershipGrantRequest { Operation string `json:"operation"`; PlanID int64 `json:"planId"`; ExpiresAt string `json:"expiresAt"`; Reason string `json:"reason"` } +type PasswordResetByAdminRequest { NewPassword string `json:"newPassword"` } +type AdminCreateUserRequest { Phone string `json:"phone"`; Password string `json:"password"`; Nickname string `json:"nickname"`; Gender int `json:"gender,optional"`; City string `json:"city,optional"`; Bio string `json:"bio,optional"` } +type SanctionRequest { Type string `json:"type"`; Reason string `json:"reason"`; ExpiresAt string `json:"expiresAt"` } +type MembershipPlanRequest { Code string `json:"code"`; Name string `json:"name"`; Level int `json:"level"`; DurationDays int `json:"durationDays"`; DailyActiveChatLimit int `json:"dailyActiveChatLimit"`; DailyLikeLimit int `json:"dailyLikeLimit"`; CanViewVisitors bool `json:"canViewVisitors"`; CanInvisibleVisit bool `json:"canInvisibleVisit"`; RecommendationWeight int `json:"recommendationWeight"`; PriceCent int `json:"priceCent"`; OriginalPriceCent int `json:"originalPriceCent"`; Status int `json:"status"`; SortOrder int `json:"sortOrder"` } +type AdminOrderUpdateRequest { ProductID int64 `json:"productId"`; AmountCent int `json:"amountCent"`; Channel string `json:"channel"`; Status string `json:"status"` } +type AdminAccountRequest { Username string `json:"username"`; DisplayName string `json:"displayName"`; Password string `json:"password"`; Status int `json:"status"`; RoleIDs []int64 `json:"roleIds"` } +type AdminRoleRequest { Code string `json:"code"`; Name string `json:"name"`; Description string `json:"description"`; Status int `json:"status"`; Permissions []string `json:"permissions"` } +type MessageModerationRequest { Action string `json:"action"`; Reason string `json:"reason"` } +type FeedbackHandleRequest { Status string `json:"status"`; Reply string `json:"reply"` } +type AccountClosureHandleRequest { Action string `json:"action"`; Reason string `json:"reason"` } +type AppVersionRequest { Platform string `json:"platform"`; Version string `json:"version"`; BuildNumber int `json:"buildNumber"`; ForceUpdate bool `json:"forceUpdate"`; DownloadURL string `json:"downloadUrl"`; ReleaseNotes string `json:"releaseNotes"`; Status int `json:"status"` } + +@server (prefix: /api/v1) +service social-api { + @handler Login + post /auth/login/password (LoginRequest) returns (LoginResponse) + @handler SendSMS + post /auth/sms/send (SMSRequest) + @handler ResetPassword + post /auth/password/reset (ResetPasswordRequest) + @handler Me + get /me + @handler MyVerification + get /me/verification + @handler SubmitVerification + post /me/verification (UserVerificationRequest) + @handler SearchUsers + get /users/search + @handler Following + get /me/following + @handler Followers + get /me/followers + @handler Visitors + get /me/visitors + @handler Privacy + get /me/privacy + @handler NotificationSettings + get /me/notification-settings + @handler UpdateNotificationSettings + put /me/notification-settings + @handler MyDevices + get /me/devices + @handler MyFeedback + get /me/feedback + @handler SubmitFeedback + post /me/feedback + @handler AccountClosure + get /me/account-closure + @handler MyOrders + get /me/orders + @handler MyReports + get /me/reports + @handler Discover + get /discover/recommendations + @handler Nearby + get /nearby/users + @handler Feed + get /feed + @handler CreatePost + post /posts (CreatePostRequest) + @handler PostDetail + get /posts/:id + @handler Comments + get /posts/:id/comments + @handler Conversations + get /im/conversations + @handler Messages + get /im/conversations/:id/messages + @handler SendMessage + post /im/conversations/:id/messages (SendMessageRequest) + @handler RecallMessage + post /im/messages/:id/recall + @handler MembershipPlans + get /membership/plans + @handler PaymentChannels + get /payment/channels + @handler CreateOrder + post /orders + @handler PayOrder + post /orders/:id/pay +} + +@server (prefix: /admin/v1) +service social-admin-api { + @handler AdminCreateUser + post /users (AdminCreateUserRequest) + @handler AdminUsers + get /users + @handler AdminUserDetail + get /users/:id + @handler AdminUpdateProfile + put /users/:id/profile (AdminProfileRequest) + @handler AdminUpdateVerification + put /users/:id/verification (VerificationRequest) + @handler AdminUpdateMembership + put /users/:id/membership (MembershipGrantRequest) + @handler AdminResetPassword + post /users/:id/password-reset (PasswordResetByAdminRequest) + @handler AdminForceLogout + post /users/:id/force-logout + @handler AdminUserSanctions + get /users/:id/sanctions + @handler AdminCreateSanction + post /users/:id/sanctions (SanctionRequest) + @handler AdminRevokeSanction + post /sanctions/:id/revoke + @handler AdminOrders + get /orders + @handler AdminUpdateOrder + put /orders/:id (AdminOrderUpdateRequest) + @handler AdminDeleteOrder + delete /orders/:id + @handler AdminMarkOrderPaid + post /orders/:id/pay + @handler AdminCloseOrder + post /orders/:id/close + @handler AdminRefundOrder + post /orders/:id/refund + @handler AdminMembershipPlans + get /membership/plans + @handler AdminCreateMembershipPlan + post /membership/plans (MembershipPlanRequest) + @handler AdminUpdateMembershipPlan + put /membership/plans/:id (MembershipPlanRequest) + @handler AdminDeleteMembershipPlan + delete /membership/plans/:id + @handler AdminMessages + get /messages + @handler AdminModerateMessage + post /messages/:id/moderate (MessageModerationRequest) + @handler AdminFeedback + get /client-feedback + @handler AdminHandleFeedback + put /client-feedback/:id (FeedbackHandleRequest) + @handler AdminAccountClosures + get /account-closures + @handler AdminCancelAccountClosure + put /account-closures/:id (AccountClosureHandleRequest) + @handler AdminAppVersions + get /app-versions + @handler AdminCreateAppVersion + post /app-versions (AppVersionRequest) + @handler AdminUpdateAppVersion + put /app-versions/:id (AppVersionRequest) + @handler AdminDeleteAppVersion + delete /app-versions/:id + @handler AdminAccounts + get /admin-users + @handler AdminCreateAccount + post /admin-users (AdminAccountRequest) + @handler AdminUpdateAccount + put /admin-users/:id (AdminAccountRequest) + @handler AdminRoles + get /admin-roles + @handler AdminCreateRole + post /admin-roles (AdminRoleRequest) + @handler AdminUpdateRole + put /admin-roles/:id (AdminRoleRequest) + @handler AdminDeleteRole + delete /admin-roles/:id + @handler AdminPermissions + get /admin-permissions +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/go.mod b/im/.deploy/avatars-20260831-175508/source/backend/go.mod new file mode 100644 index 0000000..78a7b1a --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/go.mod @@ -0,0 +1,75 @@ +module github.com/example/xingyu + +go 1.27.0 + +require ( + github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.6.0 + github.com/disintegration/imaging v1.6.2 + github.com/go-sql-driver/mysql v1.10.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/gorilla/websocket v1.5.3 + github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible + github.com/qiniu/go-sdk/v7 v7.27.0 + github.com/tencentyun/cos-go-sdk-v5 v0.7.75 + github.com/zeromicro/go-zero v1.10.3 + golang.org/x/crypto v0.48.0 + golang.org/x/image v0.45.0 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/BurntSushi/toml v1.3.2 // indirect + github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clbanning/mxj v1.8.4 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gofrs/flock v0.8.1 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/google/go-querystring v1.0.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grafana/pyroscope-go v1.3.0 // indirect + github.com/grafana/pyroscope-go/godeltaprof v0.1.10 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.4.3 // indirect + github.com/mozillazg/go-httpheader v0.2.1 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/openzipkin/zipkin-go v0.4.3 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/titanous/json5 v1.0.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/zipkin v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/net v0.50.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 + golang.org/x/time v0.14.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + modernc.org/fileutil v1.0.0 // indirect +) diff --git a/im/.deploy/avatars-20260831-175508/source/backend/go.sum b/im/.deploy/avatars-20260831-175508/source/backend/go.sum new file mode 100644 index 0000000..8e4dd77 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/go.sum @@ -0,0 +1,193 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 h1:7dONQ3WNZ1zy960TmkxJPuwoolZwL7xKtpcM04MBnt4= +github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82/go.mod h1:nLnM0KdK1CmygvjpDUO6m1TjSsiQtL61juhNsvV/JVI= +github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.6.0 h1:uWzn3io54f9L9mvwsQQSv1KpkkFA06hBxI++RvIyvpI= +github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.6.0/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I= +github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grafana/pyroscope-go v1.3.0 h1:t3Jehad8vvqN4oRAB0LdmfQ5ZSUXQw3asoft+K4GAT8= +github.com/grafana/pyroscope-go v1.3.0/go.mod h1:XA7I3usNx+UdjOZfQnl1WV8y924vsJo9KIVrKB+9jx4= +github.com/grafana/pyroscope-go/godeltaprof v0.1.10 h1:dvhndEbyavTb59vFCd6PsrAG5qi69/qZZtegh/TJKSY= +github.com/grafana/pyroscope-go/godeltaprof v0.1.10/go.mod h1:XnWRGg2XO5uxZdiz1rfeJH6w1eZ+YICCBVXNWOfH86g= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= +github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible h1:lX3m9hvP5tSnJ8bFg/TdT2BYHj1nSBulealy5VN9mPU= +github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible/go.mod h1:l7VUhRbTKCzdOacdT4oWCwATKyvZqUOlOqr0Ous3k4s= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +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= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs= +github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mozillazg/go-httpheader v0.2.1 h1:geV7TrjbL8KXSyvghnFm+NyTux/hxwueTSrwhe88TQQ= +github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg= +github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/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/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/qiniu/go-sdk/v7 v7.27.0 h1:n+2U0S5fhbmG/lN/agO8KcYJaQDqROUVShtnp56Mkw8= +github.com/qiniu/go-sdk/v7 v7.27.0/go.mod h1:pTwVR1B+8SXcPLhDzBUasiKFTD9F7jRglRDR553BW3k= +github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGGF0= +github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY= +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/rs/dnscache v0.0.0-20230804202142-fc85eb664529/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +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/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0= +github.com/tencentyun/cos-go-sdk-v5 v0.7.75 h1:eRCGP5chujSYGFnsCPxlhQcN9wtlSO/4eXrxKtLVAIw= +github.com/tencentyun/cos-go-sdk-v5 v0.7.75/go.mod h1:STbTNaNKq03u+gscPEGOahKzLcGSYOj6Dzc5zNay7Pg= +github.com/tencentyun/qcloud-cos-sts-sdk v0.0.0-20250515025012-e0eec8a5d123/go.mod h1:b18KQa4IxHbxeseW1GcZox53d7J0z39VNONTxvvlkXw= +github.com/titanous/json5 v1.0.0 h1:hJf8Su1d9NuI/ffpxgxQfxh/UiBFZX7bMPid0rIL/7s= +github.com/titanous/json5 v1.0.0/go.mod h1:7JH1M8/LHKc6cyP5o5g3CSaRj+mBrIimTxzpvmckH8c= +github.com/zeromicro/go-zero v1.10.3 h1:fm4+jUuUF77IWtFeAyf2xVoBRcgEpF1NZJUqTvZ3dw0= +github.com/zeromicro/go-zero v1.10.3/go.mod h1:Gnac2bT/JGb9Ja79wchssVeYtJxuWWzL98DuLH11kds= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8= +go.opentelemetry.io/otel/exporters/zipkin v1.40.0 h1:zu+I4j+FdO6xIxBVPeuncQVbjxUM4LiMgv6GwGe9REE= +go.opentelemetry.io/otel/exporters/zipkin v1.40.0/go.mod h1:zS6cC4nFBYXbu18e7aLfMzubBjOiN7ZcROu477qtMf8= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= +golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= +gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= +gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= +gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +modernc.org/fileutil v1.0.0 h1:Z1AFLZwl6BO8A5NldQg/xTSjGLetp+1Ubvl4alfGx8w= +modernc.org/fileutil v1.0.0/go.mod h1:JHsWpkrk/CnVV1H/eGlFf85BEpfkrp56ro8nojIq9Q8= diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin.go new file mode 100644 index 0000000..99f687a --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin.go @@ -0,0 +1,818 @@ +package app + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +func (a *App) adminLogin(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "请输入账号和密码") + return + } + if !a.rateLimit(w, r, "admin_login_ip", clientIP(r), 10, 10*time.Minute) || !a.rateLimit(w, r, "admin_login_user", strings.ToLower(strings.TrimSpace(req.Username)), 10, 15*time.Minute) { + return + } + var id int64 + var hash, realName string + var status int + err := a.db.QueryRowContext(r.Context(), `SELECT id,password_hash,real_name,status FROM admin_users WHERE username=?`, req.Username).Scan(&id, &hash, &realName, &status) + if err != nil || !checkPassword(hash, req.Password) { + fail(w, 401, 10001, "账号或密码错误") + return + } + if status != 1 { + fail(w, 403, 10006, "管理员账号已停用") + return + } + payload, err := a.newAdminSession(r.Context(), w, r, id, realName) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建管理会话失败") + return + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE admin_users SET last_login_at=NOW(3) WHERE id=?`, id) + reply(w, payload) +} + +func (a *App) adminChangePassword(w http.ResponseWriter, r *http.Request) { + who := current(r) + if !a.rateLimit(w, r, "admin_password_change", strconv.FormatInt(who.ID, 10), 5, 30*time.Minute) { + return + } + var req struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` + } + if decode(r, &req) != nil || req.CurrentPassword == "" || req.NewPassword == "" { + fail(w, http.StatusBadRequest, 20001, "请输入当前密码和新密码") + return + } + + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "修改密码失败") + return + } + defer func() { _ = tx.Rollback() }() + + var passwordHash string + if err = tx.QueryRowContext(r.Context(), `SELECT password_hash FROM admin_users WHERE id=? AND status=1 FOR UPDATE`, who.ID).Scan(&passwordHash); err != nil || !checkPassword(passwordHash, req.CurrentPassword) { + fail(w, http.StatusBadRequest, 20001, "当前密码错误") + return + } + newHash, err := hashPassword(req.NewPassword) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "密码加密失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_users SET password_hash=?,token_version=token_version+1,password_changed_at=NOW(3) WHERE id=?`, newHash, who.ID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "修改密码失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_sessions SET revoked_at=NOW(3) WHERE admin_user_id=? AND revoked_at IS NULL`, who.ID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "撤销旧会话失败") + return + } + if err = tx.Commit(); err != nil { + fail(w, http.StatusInternalServerError, 50001, "修改密码失败") + return + } + a.clearAdminRefreshCookie(w) + a.audit(r, "change_password", "admin_user", who.ID, map[string]any{"allSessionsRevoked": true}) + reply(w, map[string]bool{"success": true, "reauthenticate": true}) +} + +func (a *App) adminInfo(w http.ResponseWriter, r *http.Request) { + who := current(r) + var username, realName, avatar string + if err := a.db.QueryRowContext(r.Context(), `SELECT username,real_name,avatar_url FROM admin_users WHERE id=?`, who.ID).Scan(&username, &realName, &avatar); err != nil { + fail(w, http.StatusUnauthorized, 10001, "管理员不存在") + return + } + roles, permissions, err := a.adminAccess(r.Context(), who.ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取管理员角色失败") + return + } + homePath := "/profile" + permissionHomes := []struct { + Code string + Path string + }{ + {"dashboard:view", "/analytics"}, + {"users:view", "/users"}, + {"content:view", "/posts"}, + {"messages:view", "/messages"}, + {"reports:handle", "/reports"}, + {"risk:view", "/risk"}, + {"membership:manage", "/membership"}, + {"orders:view", "/orders"}, + {"system:manage", "/configs"}, + } + permissionSet := map[string]bool{} + for _, permission := range permissions { + permissionSet[permission] = true + } + for _, candidate := range permissionHomes { + if permissionSet[candidate.Code] { + homePath = candidate.Path + break + } + } + reply(w, map[string]any{"userId": who.ID, "username": username, "realName": realName, "avatar": avatar, "roles": roles, "homePath": homePath}) +} + +func (a *App) dashboard(w http.ResponseWriter, r *http.Request) { + count := func(query string) int64 { + var value int64 + _ = a.db.QueryRowContext(r.Context(), query).Scan(&value) + return value + } + amount := func(query string) int64 { + var value sql.NullInt64 + _ = a.db.QueryRowContext(r.Context(), query).Scan(&value) + if value.Valid { + return value.Int64 + } + return 0 + } + trend := []map[string]any{} + for i := 6; i >= 0; i-- { + day := time.Now().AddDate(0, 0, -i) + var users, messages int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE is_test=0 AND DATE(created_at)=?`, day.Format("2006-01-02")).Scan(&users) + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM im_messages WHERE DATE(created_at)=?`, day.Format("2006-01-02")).Scan(&messages) + trend = append(trend, map[string]any{"date": day.Format("01-02"), "users": users, "messages": messages}) + } + reply(w, map[string]any{"metrics": map[string]any{"testUsers": count(`SELECT COUNT(*) FROM users WHERE is_test=1 AND deleted_at IS NULL`), "users": count(`SELECT COUNT(*) FROM users WHERE is_test=0 AND deleted_at IS NULL`), "newUsersToday": count(`SELECT COUNT(*) FROM users WHERE is_test=0 AND DATE(created_at)=CURDATE()`), "activeUsers": count(`SELECT COUNT(*) FROM user_profiles p JOIN users u ON u.id=p.user_id WHERE u.is_test=0 AND u.deleted_at IS NULL AND p.last_active_at>DATE_SUB(NOW(),INTERVAL 1 DAY)`), "posts": count(`SELECT COUNT(*) FROM posts WHERE status=1`), "messages": count(`SELECT COUNT(*) FROM im_messages`), "pendingReports": count(`SELECT COUNT(*) FROM reports WHERE status='PENDING'`), "paidOrders": count(`SELECT COUNT(*) FROM orders WHERE status='PAID' AND deleted_at IS NULL`), "revenueCent": amount(`SELECT SUM(amount_cent) FROM orders WHERE status='PAID' AND deleted_at IS NULL`)}, "trend": trend, "funnel": []map[string]any{{"name": "注册用户", "value": count(`SELECT COUNT(*) FROM users WHERE is_test=0`)}, {"name": "完善资料", "value": count(`SELECT COUNT(*) FROM user_profiles p JOIN users u ON u.id=p.user_id WHERE u.is_test=0 AND p.profile_score>=80`)}, {"name": "产生互动", "value": count(`SELECT COUNT(DISTINCT user_id) FROM user_likes`)}, {"name": "发起会话", "value": count(`SELECT COUNT(DISTINCT user_id) FROM im_conversation_members`)}, {"name": "付费会员", "value": count(`SELECT COUNT(DISTINCT user_id) FROM orders WHERE status='PAID' AND deleted_at IS NULL`)}}}) +} + +func (a *App) adminUsers(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + keyword := strings.TrimSpace(r.URL.Query().Get("keyword")) + status, _ := strconv.Atoi(r.URL.Query().Get("status")) + verificationStatus := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("verificationStatus"))) + where := ` WHERE u.deleted_at IS NULL` + args := []any{} + sourceWhere, sourceArgs, sourceErr := testUserFilter(r.URL.Query().Get("userType"), r.URL.Query().Get("testBatch")) + if sourceErr != nil { + fail(w, http.StatusBadRequest, 20001, sourceErr.Error()) + return + } + where += sourceWhere + args = append(args, sourceArgs...) + if keyword != "" { + where += ` AND (p.nickname LIKE ? OR u.public_id LIKE ? OR u.phone_hash=?)` + like := "%" + keyword + "%" + args = append(args, like, like, phoneHash(keyword)) + } + if status > 0 { + where += ` AND u.status=?` + args = append(args, status) + } + if verificationStatus != "" { + valid := verificationStatus == "UNVERIFIED" || verificationStatus == "PENDING" || verificationStatus == "VERIFIED" || verificationStatus == "REJECTED" + if !valid { + fail(w, http.StatusBadRequest, 20001, "认证状态筛选无效") + return + } + if verificationStatus == "UNVERIFIED" { + where += ` AND NOT EXISTS(SELECT 1 FROM user_verifications vx WHERE vx.user_id=u.id AND vx.verification_type='real_name' AND vx.status<>'UNVERIFIED')` + } else { + where += ` AND EXISTS(SELECT 1 FROM user_verifications vx WHERE vx.user_id=u.id AND vx.verification_type='real_name' AND vx.status=?)` + args = append(args, verificationStatus) + } + } + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users u JOIN user_profiles p ON p.user_id=u.id`+where, args...).Scan(&total) + query := `SELECT u.id,u.public_id,u.phone_cipher,u.status,u.risk_level,u.created_at,u.is_test,u.test_batch,p.nickname,p.avatar_url,p.gender,p.city_name,p.is_vip,p.vip_level,p.last_active_at,COALESCE(v.status,'UNVERIFIED'),(SELECT MAX(s.expires_at) FROM subscriptions s WHERE s.user_id=u.id AND s.status=1 AND s.expires_at>NOW(3)) FROM users u JOIN user_profiles p ON p.user_id=u.id LEFT JOIN user_verifications v ON v.user_id=u.id AND v.verification_type='real_name'` + where + ` ORDER BY u.created_at DESC,u.id DESC LIMIT ? OFFSET ?` + args = append(args, size, offset) + rows, err := a.db.QueryContext(r.Context(), query, args...) + if err != nil { + fail(w, 500, 50001, err.Error()) + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var publicID, nickname, avatar, city, verificationStatus string + var phoneCipher []byte + var status, risk, gender, vip, vipLevel int + var created time.Time + var active, vipExpiresAt sql.NullTime + var isTest bool + var testBatch string + if err = rows.Scan(&id, &publicID, &phoneCipher, &status, &risk, &created, &isTest, &testBatch, &nickname, &avatar, &gender, &city, &vip, &vipLevel, &active, &verificationStatus, &vipExpiresAt); err != nil { + fail(w, 500, 50001, "读取用户列表失败") + return + } + phone, _ := a.decryptPhone(phoneCipher) + items = append(items, map[string]any{"id": id, "publicId": publicID, "isTest": isTest, "testBatch": testBatch, "phone": maskPhone(phone), "nickname": nickname, "avatar": avatar, "gender": gender, "city": city, "vip": vip > 0, "vipLevel": vipLevel, "vipExpiresAt": nullableTime(vipExpiresAt), "verificationStatus": verificationStatus, "status": status, "riskLevel": risk, "lastActiveAt": nullableTime(active), "createdAt": created}) + } + if rows.Err() != nil { + fail(w, 500, 50001, "读取用户列表失败") + return + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} + +func (a *App) adminUserDetail(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "invalid id") + return + } + a.loadAdminUserDetail(w, r, id) +} + +func (a *App) adminUserStatus(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "invalid id") + return + } + status := 1 + action := "unban" + switch { + case strings.HasSuffix(r.URL.Path, "/freeze"): + status = 2 + action = "freeze" + case strings.HasSuffix(r.URL.Path, "/ban"): + status = 3 + action = "ban" + case strings.HasSuffix(r.URL.Path, "/unfreeze"): + action = "unfreeze" + } + var exists int + if a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE id=? AND deleted_at IS NULL`, id).Scan(&exists) != nil || exists == 0 { + fail(w, 404, 30001, "用户不存在") + return + } + if status != 1 { + typ := "FREEZE" + if status == 3 { + typ = "BAN" + } + if _, err = a.createSanction(r.Context(), current(r).ID, id, typ, "管理员执行账号状态操作", nil); err != nil { + fail(w, 500, 50001, "操作失败") + return + } + } else { + typ := "FREEZE" + if action == "unban" { + typ = "BAN" + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE user_sanctions SET status='REVOKED',revoked_by=?,revoked_at=NOW(3) WHERE user_id=? AND sanction_type=? AND status='ACTIVE'`, current(r).ID, id, typ) + var bans, freezes int + _ = a.db.QueryRowContext(r.Context(), `SELECT SUM(sanction_type='BAN'),SUM(sanction_type='FREEZE') FROM user_sanctions WHERE user_id=? AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>NOW(3))`, id).Scan(&bans, &freezes) + if bans > 0 { + status = 3 + } else if freezes > 0 { + status = 2 + } + _, err = a.db.ExecContext(r.Context(), `UPDATE users SET status=? WHERE id=?`, status, id) + if err != nil { + fail(w, 500, 50001, "操作失败") + return + } + } + a.audit(r, action, "user", id, map[string]any{"status": status}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminPosts(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL`).Scan(&total) + rows, err := a.db.QueryContext(r.Context(), `SELECT po.id,po.content,po.location_text,po.status,po.moderation_status,po.like_count,po.comment_count,po.created_at,p.user_id,p.nickname,p.avatar_url FROM posts po JOIN user_profiles p ON p.user_id=po.user_id WHERE po.deleted_at IS NULL ORDER BY po.created_at DESC LIMIT ? OFFSET ?`, size, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, userID int64 + var content, location, nickname, avatar string + var status, moderation, likes, comments int + var created time.Time + _ = rows.Scan(&id, &content, &location, &status, &moderation, &likes, &comments, &created, &userID, &nickname, &avatar) + items = append(items, map[string]any{"id": id, "content": content, "location": location, "status": status, "moderationStatus": moderation, "likeCount": likes, "commentCount": comments, "createdAt": created, "user": map[string]any{"id": userID, "nickname": nickname, "avatar": avatar}}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} + +func (a *App) adminPostDetail(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态 ID 无效") + return + } + var userID int64 + var publicID, nickname, avatar, cityName, content, cityCode, location string + var gender, vip, visibility, status, moderation, likes, comments int + var created, updated time.Time + err = a.db.QueryRowContext(r.Context(), `SELECT po.user_id,u.public_id,p.nickname,p.avatar_url,p.gender,p.city_name,p.is_vip,po.content,po.visibility,po.city_code,po.location_text,po.status,po.moderation_status,po.like_count,po.comment_count,po.created_at,po.updated_at FROM posts po JOIN users u ON u.id=po.user_id JOIN user_profiles p ON p.user_id=po.user_id WHERE po.id=? AND po.deleted_at IS NULL`, id).Scan(&userID, &publicID, &nickname, &avatar, &gender, &cityName, &vip, &content, &visibility, &cityCode, &location, &status, &moderation, &likes, &comments, &created, &updated) + if err == sql.ErrNoRows { + fail(w, 404, 20004, "动态不存在或已下架") + return + } + if err != nil { + fail(w, 500, 50001, "查询动态详情失败") + return + } + + media := []map[string]any{} + mediaRows, mediaErr := a.db.QueryContext(r.Context(), `SELECT id,media_url,media_type,sort_order FROM post_media WHERE post_id=? ORDER BY sort_order,id`, id) + if mediaErr == nil { + defer mediaRows.Close() + for mediaRows.Next() { + var mediaID int64 + var mediaURL, mediaType string + var sortOrder int + if mediaRows.Scan(&mediaID, &mediaURL, &mediaType, &sortOrder) == nil { + media = append(media, map[string]any{"id": mediaID, "url": mediaURL, "type": mediaType, "sortOrder": sortOrder}) + } + } + } + + commentItems := []map[string]any{} + commentRows, commentErr := a.db.QueryContext(r.Context(), `SELECT c.id,c.user_id,p.nickname,p.avatar_url,c.parent_comment_id,c.reply_user_id,c.content,c.status,c.moderation_status,c.created_at FROM post_comments c JOIN user_profiles p ON p.user_id=c.user_id WHERE c.post_id=? AND c.deleted_at IS NULL ORDER BY c.created_at DESC LIMIT 100`, id) + if commentErr == nil { + defer commentRows.Close() + for commentRows.Next() { + var commentID, commentUserID int64 + var commentNickname, commentAvatar, commentContent string + var parentID, replyUserID sql.NullInt64 + var commentStatus, commentModeration int + var commentCreated time.Time + if commentRows.Scan(&commentID, &commentUserID, &commentNickname, &commentAvatar, &parentID, &replyUserID, &commentContent, &commentStatus, &commentModeration, &commentCreated) == nil { + commentItems = append(commentItems, map[string]any{"id": commentID, "userId": commentUserID, "nickname": commentNickname, "avatar": commentAvatar, "parentCommentId": nullableID(parentID), "replyUserId": nullableID(replyUserID), "content": commentContent, "status": commentStatus, "moderationStatus": commentModeration, "createdAt": commentCreated}) + } + } + } + + likeItems := []map[string]any{} + likeRows, likeErr := a.db.QueryContext(r.Context(), `SELECT l.user_id,p.nickname,p.avatar_url,l.created_at FROM post_likes l JOIN user_profiles p ON p.user_id=l.user_id WHERE l.post_id=? ORDER BY l.created_at DESC LIMIT 100`, id) + if likeErr == nil { + defer likeRows.Close() + for likeRows.Next() { + var likeUserID int64 + var likeNickname, likeAvatar string + var likeCreated time.Time + if likeRows.Scan(&likeUserID, &likeNickname, &likeAvatar, &likeCreated) == nil { + likeItems = append(likeItems, map[string]any{"userId": likeUserID, "nickname": likeNickname, "avatar": likeAvatar, "createdAt": likeCreated}) + } + } + } + + var reportCount int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM reports WHERE target_type='post' AND target_id=?`, id).Scan(&reportCount) + reply(w, map[string]any{ + "id": id, "content": content, "visibility": visibility, "cityCode": cityCode, "location": location, + "status": status, "moderationStatus": moderation, "likeCount": likes, "commentCount": comments, + "reportCount": reportCount, "createdAt": created, "updatedAt": updated, "media": media, + "comments": commentItems, "likes": likeItems, + "user": map[string]any{"id": userID, "publicId": publicID, "nickname": nickname, "avatar": avatar, "gender": gender, "city": cityName, "vip": vip == 1}, + }) +} + +func nullableID(value sql.NullInt64) any { + if value.Valid { + return value.Int64 + } + return nil +} + +func (a *App) adminDeletePost(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE posts SET status=0,deleted_at=NOW(3) WHERE id=? AND status=1`, id) + if err != nil { + fail(w, 500, 50001, "删除失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 404, 30001, "动态不存在或已下架") + return + } + a.audit(r, "delete", "post", id, nil) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminReports(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + status := r.URL.Query().Get("status") + where := "" + args := []any{} + if status != "" { + where = ` WHERE r.status=?` + args = append(args, status) + } + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM reports r`+where, args...).Scan(&total) + args = append(args, size, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT r.id,r.reporter_user_id,p.nickname,r.target_type,r.target_id,r.reason_code,r.description,COALESCE(CAST(r.evidence_json AS CHAR CHARACTER SET utf8mb4),'[]'),r.status,r.action_type,r.handle_remark,r.handled_by,COALESCE(handler.real_name,''),r.handled_at,r.created_at FROM reports r JOIN user_profiles p ON p.user_id=r.reporter_user_id LEFT JOIN admin_users handler ON handler.id=r.handled_by`+where+` ORDER BY r.created_at DESC LIMIT ? OFFSET ?`, args...) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, reporter, target int64 + var nick, targetType, reason, description, evidenceJSON, status, actionType, handleRemark, handlerName string + var handler, handled any + var created time.Time + _ = rows.Scan(&id, &reporter, &nick, &targetType, &target, &reason, &description, &evidenceJSON, &status, &actionType, &handleRemark, &handler, &handlerName, &handled, &created) + evidence := []string{} + _ = json.Unmarshal([]byte(evidenceJSON), &evidence) + items = append(items, map[string]any{"id": id, "reporterId": reporter, "reporterName": nick, "targetType": targetType, "targetId": target, "reason": reason, "description": description, "evidence": evidence, "status": status, "actionType": actionType, "handleRemark": handleRemark, "handledBy": handler, "handlerName": handlerName, "handledAt": handled, "createdAt": created}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} +func (a *App) adminHandleReport(w http.ResponseWriter, r *http.Request) { + id, pathErr := pathID(r) + if pathErr != nil { + fail(w, 400, 20001, "举报编号无效") + return + } + var req struct { + Result string `json:"result"` + Remark string `json:"remark"` + SanctionType string `json:"sanctionType"` + DurationDays int `json:"durationDays"` + } + if decode(r, &req) != nil || (req.Result != "punished" && req.Result != "dismissed") { + fail(w, 400, 20001, "处理结果无效") + return + } + status := "REJECTED" + actionType := "NONE" + if req.Result == "punished" { + status = "PUNISHED" + } + var targetType string + var targetID, reporterID int64 + if a.db.QueryRowContext(r.Context(), `SELECT target_type,target_id,reporter_user_id FROM reports WHERE id=? AND status='PENDING'`, id).Scan(&targetType, &targetID, &reporterID) != nil { + fail(w, 404, 30001, "待处理举报不存在") + return + } + if status == "PUNISHED" { + if len([]rune(strings.TrimSpace(req.Remark))) < 2 { + fail(w, 400, 20001, "请填写明确的处理原因") + return + } + if targetType == "user" { + typ := strings.ToUpper(strings.TrimSpace(req.SanctionType)) + if typ == "" { + typ = "WARNING" + } + actionType = typ + if !map[string]bool{"WARNING": true, "FREEZE": true, "BAN": true, "MUTE": true, "CONTENT_LIMIT": true}[typ] { + fail(w, 400, 20001, "处罚类型无效") + return + } + if typ != "WARNING" && typ != "BAN" && (req.DurationDays < 1 || req.DurationDays > 3650) { + fail(w, 400, 20001, "临时处罚期限应为 1-3650 天") + return + } + var expiry *time.Time + if typ != "WARNING" && typ != "BAN" { + value := time.Now().AddDate(0, 0, req.DurationDays) + expiry = &value + } + if _, err := a.createSanction(r.Context(), current(r).ID, targetID, typ, "举报处理:"+strings.TrimSpace(req.Remark), expiry); err != nil { + fail(w, 500, 50001, "处罚执行失败") + return + } + } else if targetType == "post" { + actionType = "CONTENT_REMOVE" + result, actionErr := a.db.ExecContext(r.Context(), `UPDATE posts SET status=0,deleted_at=COALESCE(deleted_at,NOW(3)) WHERE id=? AND status=1`, targetID) + var affected int64 + if actionErr == nil { + affected, _ = result.RowsAffected() + } + if actionErr != nil || affected == 0 { + fail(w, 409, 20001, "动态已下架或不存在") + return + } + } else if targetType == "comment" { + actionType = "CONTENT_REMOVE" + tx, beginErr := a.db.BeginTx(r.Context(), nil) + if beginErr != nil { + fail(w, 500, 50001, "评论处置失败") + return + } + var postID int64 + if beginErr = tx.QueryRowContext(r.Context(), `SELECT post_id FROM post_comments WHERE id=? AND status=1 FOR UPDATE`, targetID).Scan(&postID); beginErr == nil { + _, beginErr = tx.ExecContext(r.Context(), `UPDATE post_comments SET status=0,deleted_at=COALESCE(deleted_at,NOW(3)) WHERE id=?`, targetID) + } + if beginErr == nil { + _, beginErr = tx.ExecContext(r.Context(), `UPDATE posts SET comment_count=GREATEST(comment_count-1,0) WHERE id=?`, postID) + } + if beginErr != nil || tx.Commit() != nil { + _ = tx.Rollback() + fail(w, 409, 20001, "评论已删除或不存在") + return + } + } else if targetType == "message" { + actionType = "CONTENT_REMOVE" + var conversationID, seq int64 + if a.db.QueryRowContext(r.Context(), `SELECT conversation_id,seq FROM im_messages WHERE id=?`, targetID).Scan(&conversationID, &seq) != nil { + fail(w, 404, 30001, "消息不存在") + return + } + if _, actionErr := a.db.ExecContext(r.Context(), `UPDATE im_messages SET moderation_status=0,admin_removed_at=NOW(3),admin_removed_by=?,admin_remove_reason=? WHERE id=?`, current(r).ID, strings.TrimSpace(req.Remark), targetID); actionErr != nil { + fail(w, 500, 50001, "消息处置失败") + return + } + rows, _ := a.db.QueryContext(r.Context(), `SELECT user_id FROM im_conversation_members WHERE conversation_id=? AND status=1`, conversationID) + members := []int64{} + if rows != nil { + for rows.Next() { + var memberID int64 + _ = rows.Scan(&memberID) + members = append(members, memberID) + } + _ = rows.Close() + } + a.hub.broadcast(members, map[string]any{"command": "MESSAGE_RECALLED", "data": map[string]any{"id": targetID, "conversationId": conversationID, "seq": seq}}) + } else { + fail(w, 400, 20001, "举报目标类型无效") + return + } + } + result, err := a.db.ExecContext(r.Context(), `UPDATE reports SET status=?,action_type=?,handle_remark=?,handled_by=?,handled_at=NOW(3) WHERE id=? AND status='PENDING'`, status, actionType, strings.TrimSpace(req.Remark), current(r).ID, id) + var affected int64 + if err == nil { + affected, _ = result.RowsAffected() + } + if err != nil || affected == 0 { + fail(w, 500, 50001, "处理失败") + return + } + a.audit(r, "handle", "report", id, req) + content := "举报已处理,感谢你帮助维护社区环境。" + if status == "REJECTED" { + content = "本次举报经核查暂未发现违规,感谢你的反馈。" + } + a.notifyUser(r.Context(), reporterID, "system", "举报处理结果", content, "report", id) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminRiskUsers(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_risk_profiles`).Scan(&total) + rows, err := a.db.QueryContext(r.Context(), `SELECT rp.user_id,p.nickname,p.avatar_url,rp.risk_score,rp.risk_level,rp.message_score,rp.device_score,rp.report_score,rp.behavior_score,rp.updated_at FROM user_risk_profiles rp JOIN user_profiles p ON p.user_id=rp.user_id ORDER BY rp.risk_score DESC LIMIT ? OFFSET ?`, size, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var nick, avatar string + var score, level, message, device, report, behavior int + var updated time.Time + _ = rows.Scan(&id, &nick, &avatar, &score, &level, &message, &device, &report, &behavior, &updated) + items = append(items, map[string]any{"userId": id, "nickname": nick, "avatar": avatar, "riskScore": score, "riskLevel": level, "messageScore": message, "deviceScore": device, "reportScore": report, "behaviorScore": behavior, "updatedAt": updated}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} +func (a *App) adminRiskEvents(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM risk_events`).Scan(&total) + rows, err := a.db.QueryContext(r.Context(), `SELECT e.id,e.user_id,p.nickname,e.event_type,e.score_delta,e.device_id,e.ip,e.metadata,e.created_at FROM risk_events e JOIN user_profiles p ON p.user_id=e.user_id ORDER BY e.created_at DESC LIMIT ? OFFSET ?`, size, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, userID int64 + var nick, typ, device, ip string + var delta int + var metadata []byte + var created time.Time + _ = rows.Scan(&id, &userID, &nick, &typ, &delta, &device, &ip, &metadata, &created) + var meta any + _ = json.Unmarshal(metadata, &meta) + items = append(items, map[string]any{"id": id, "userId": userID, "nickname": nick, "eventType": typ, "scoreDelta": delta, "deviceId": device, "ip": ip, "metadata": meta, "createdAt": created}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} + +func (a *App) adminPlans(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,code,name,level,duration_days,daily_active_chat_limit,daily_like_limit,can_view_visitors,can_invisible_visit,recommendation_weight,price_cent,original_price_cent,status,sort_order FROM membership_plans WHERE deleted_at IS NULL ORDER BY sort_order,id`) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []planView{} + for rows.Next() { + var item planView + _ = rows.Scan(&item.ID, &item.Code, &item.Name, &item.Level, &item.DurationDays, &item.DailyActiveChatLimit, &item.DailyLikeLimit, &item.CanViewVisitors, &item.CanInvisibleVisit, &item.RecommendationWeight, &item.PriceCent, &item.OriginalPriceCent, &item.Status, &item.SortOrder) + items = append(items, item) + } + reply(w, map[string]any{"items": items, "total": len(items)}) +} +func (a *App) adminCreatePlan(w http.ResponseWriter, r *http.Request) { + var req planView + if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" || strings.TrimSpace(req.Name) == "" || req.Level < 1 || req.Level > 20 || req.DurationDays < 1 || req.DailyActiveChatLimit < 0 || req.DailyActiveChatLimit > 10000 || req.DailyLikeLimit < 0 || req.DailyLikeLimit > 100000 || req.RecommendationWeight < 0 || req.RecommendationWeight > 10000 || req.PriceCent < 0 || req.OriginalPriceCent < req.PriceCent { + fail(w, 400, 20001, "请完整填写套餐信息,原价不能低于售价") + return + } + req.Code = strings.ToUpper(strings.TrimSpace(req.Code)) + req.Name = strings.TrimSpace(req.Name) + result, err := a.db.ExecContext(r.Context(), `INSERT INTO membership_plans(code,name,level,duration_days,daily_active_chat_limit,daily_like_limit,can_view_visitors,can_invisible_visit,recommendation_weight,price_cent,original_price_cent,status,sort_order)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, req.Code, req.Name, req.Level, req.DurationDays, req.DailyActiveChatLimit, req.DailyLikeLimit, req.CanViewVisitors, req.CanInvisibleVisit, req.RecommendationWeight, req.PriceCent, req.OriginalPriceCent, req.Status, req.SortOrder) + if err != nil { + fail(w, http.StatusConflict, 20001, "套餐编码已存在或数据无效") + return + } + id, _ := result.LastInsertId() + a.audit(r, "create", "membership_plan", id, req) + reply(w, map[string]any{"id": id}) +} +func (a *App) adminUpdatePlan(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + var req planView + if err != nil || decode(r, &req) != nil || strings.TrimSpace(req.Name) == "" || req.Level < 1 || req.Level > 20 || req.DurationDays < 1 || req.DailyActiveChatLimit < 0 || req.DailyActiveChatLimit > 10000 || req.DailyLikeLimit < 0 || req.DailyLikeLimit > 100000 || req.RecommendationWeight < 0 || req.RecommendationWeight > 10000 || req.PriceCent < 0 || req.OriginalPriceCent < req.PriceCent { + fail(w, 400, 20001, "套餐信息不完整或价格无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE membership_plans SET name=?,level=?,duration_days=?,daily_active_chat_limit=?,daily_like_limit=?,can_view_visitors=?,can_invisible_visit=?,recommendation_weight=?,price_cent=?,original_price_cent=?,status=?,sort_order=? WHERE id=? AND deleted_at IS NULL`, strings.TrimSpace(req.Name), req.Level, req.DurationDays, req.DailyActiveChatLimit, req.DailyLikeLimit, req.CanViewVisitors, req.CanInvisibleVisit, req.RecommendationWeight, req.PriceCent, req.OriginalPriceCent, req.Status, req.SortOrder, id) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 404, 30001, "套餐不存在或已删除") + return + } + a.audit(r, "update", "membership_plan", id, req) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminOrders(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + status := strings.TrimSpace(r.URL.Query().Get("status")) + keyword := strings.TrimSpace(r.URL.Query().Get("keyword")) + where := ` WHERE o.deleted_at IS NULL` + args := []any{} + if status != "" { + where += ` AND o.status=?` + args = append(args, status) + } + if keyword != "" { + where += ` AND (o.order_no LIKE ? OR p.nickname LIKE ? OR u.public_id LIKE ?)` + like := "%" + keyword + "%" + args = append(args, like, like, like) + } + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM orders o JOIN users u ON u.id=o.user_id JOIN user_profiles p ON p.user_id=o.user_id`+where, args...).Scan(&total) + args = append(args, size, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT o.id,o.order_no,o.user_id,u.public_id,p.nickname,o.product_id,COALESCE(mp.name,''),o.amount_cent,o.currency,o.status,o.channel,o.paid_at,o.created_at FROM orders o JOIN users u ON u.id=o.user_id JOIN user_profiles p ON p.user_id=o.user_id LEFT JOIN membership_plans mp ON mp.id=o.product_id`+where+` ORDER BY o.created_at DESC LIMIT ? OFFSET ?`, args...) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, userID, productID int64 + var orderNo, publicID, nickname, product, currency, orderStatus, channel string + var amount int + var paid any + var created time.Time + _ = rows.Scan(&id, &orderNo, &userID, &publicID, &nickname, &productID, &product, &amount, ¤cy, &orderStatus, &channel, &paid, &created) + items = append(items, map[string]any{"id": id, "orderNo": orderNo, "userId": userID, "publicId": publicID, "nickname": nickname, "productId": productID, "productName": product, "amountCent": amount, "currency": currency, "status": orderStatus, "channel": channel, "paidAt": paid, "createdAt": created}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} +func (a *App) adminRefund(w http.ResponseWriter, r *http.Request) { + if a.configPlain(r.Context(), "payment.mode", "sandbox") == "live" { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "订单编号无效") + return + } + a.requestLiveRefund(w, r, id) + return + } + a.adminOrderTransition(w, r, "refund") +} + +func (a *App) adminConfigs(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT config_key,config_value,value_type,description,updated_at FROM system_configs WHERE config_key NOT LIKE 'sms.%' AND config_key NOT LIKE 'payment.%' AND config_key NOT LIKE 'storage.%' AND config_key NOT LIKE 'oauth.%' ORDER BY config_key`) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var key, value, typ, description string + var updated time.Time + _ = rows.Scan(&key, &value, &typ, &description, &updated) + if typ == "secret" && value != "" { + value = maskedSecret + } + items = append(items, map[string]any{"key": key, "value": value, "type": typ, "description": description, "updatedAt": updated}) + } + reply(w, map[string]any{"items": items}) +} +func (a *App) adminUpdateConfig(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + key, _ := url.PathUnescape(parts[len(parts)-1]) + var req struct { + Value string `json:"value"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "invalid value") + return + } + var valueType string + if err := a.db.QueryRowContext(r.Context(), `SELECT value_type FROM system_configs WHERE config_key=?`, key).Scan(&valueType); err != nil { + fail(w, 404, 30001, "配置项不存在") + return + } + if valueType == "secret" { + if req.Value == maskedSecret || req.Value == "" { + reply(w, map[string]bool{"success": true}) + return + } + encrypted, err := a.encryptSecret(req.Value) + if err != nil { + fail(w, 500, 50001, "加密敏感配置失败") + return + } + req.Value = encrypted + } + _, err := a.db.ExecContext(r.Context(), `UPDATE system_configs SET config_value=? WHERE config_key=?`, req.Value, key) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + a.audit(r, "update", "system_config", 0, map[string]any{"key": key, "secret": valueType == "secret"}) + reply(w, map[string]bool{"success": true}) +} +func (a *App) adminAuditLogs(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + var total int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM admin_audit_logs`).Scan(&total) + rows, err := a.db.QueryContext(r.Context(), `SELECT l.id,l.admin_user_id,a.real_name,l.action,l.target_type,l.target_id,l.request_data,l.ip,l.created_at FROM admin_audit_logs l JOIN admin_users a ON a.id=l.admin_user_id ORDER BY l.created_at DESC LIMIT ? OFFSET ?`, size, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, adminID int64 + var name, action, targetType, ip string + var targetID any + var data []byte + var created time.Time + _ = rows.Scan(&id, &adminID, &name, &action, &targetType, &targetID, &data, &ip, &created) + var request any + _ = json.Unmarshal(data, &request) + items = append(items, map[string]any{"id": id, "adminId": adminID, "adminName": name, "action": action, "targetType": targetType, "targetId": targetID, "request": request, "ip": ip, "createdAt": created}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} + +func (a *App) audit(r *http.Request, action, targetType string, targetID int64, data any) { + payload, _ := json.Marshal(data) + var id any + if targetID > 0 { + id = targetID + } + _, _ = a.db.ExecContext(r.Context(), `INSERT INTO admin_audit_logs(admin_user_id,action,target_type,target_id,request_data,ip)VALUES(?,?,?,?,?,?)`, current(r).ID, action, targetType, id, payload, clientIP(r)) +} +func maskPhone(value string) string { + if len(value) >= 7 { + return value[:3] + "****" + value[len(value)-4:] + } + return value +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_client_operations.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_client_operations.go new file mode 100644 index 0000000..d11ac38 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_client_operations.go @@ -0,0 +1,247 @@ +package app + +import ( + "database/sql" + "encoding/json" + "net/http" + "strconv" + "strings" + "time" +) + +func decodeStringArray(value string) []string { + items := []string{} + _ = json.Unmarshal([]byte(value), &items) + return items +} + +func (a *App) adminFeedback(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + page, size, offset := pagination(r) + status := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("status"))) + where, args := "", []any{} + if status != "" { + where = " WHERE f.status=?" + args = append(args, status) + } + var total int64 + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_feedback f`+where, args...).Scan(&total); err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询反馈失败") + return + } + queryArgs := append(append([]any{}, args...), size, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.user_id,u.public_id,p.nickname,p.avatar_url,f.category,f.content,f.contact,COALESCE(f.evidence_json,'[]'),f.status,f.reply_content,f.handled_by,f.handled_at,f.created_at,f.updated_at FROM user_feedback f JOIN users u ON u.id=f.user_id JOIN user_profiles p ON p.user_id=f.user_id`+where+` ORDER BY f.created_at DESC LIMIT ? OFFSET ?`, queryArgs...) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询反馈失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, userID int64 + var publicID, nickname, avatar, category, content, contact, evidence, itemStatus, replyContent string + var handledBy sql.NullInt64 + var handledAt sql.NullTime + var createdAt, updatedAt time.Time + if err = rows.Scan(&id, &userID, &publicID, &nickname, &avatar, &category, &content, &contact, &evidence, &itemStatus, &replyContent, &handledBy, &handledAt, &createdAt, &updatedAt); err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取反馈失败") + return + } + items = append(items, map[string]any{"id": id, "userId": userID, "publicId": publicID, "nickname": nickname, "avatar": avatar, "category": category, "content": content, "contact": contact, "evidence": decodeStringArray(evidence), "status": itemStatus, "replyContent": replyContent, "handledBy": nullableInt64(handledBy), "handledAt": nullableTime(handledAt), "createdAt": createdAt, "updatedAt": updatedAt}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) + return + } + id, err := pathID(r) + var req struct { + Status string `json:"status"` + Reply string `json:"reply"` + } + if err != nil || decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "反馈参数无效") + return + } + req.Status = strings.ToUpper(strings.TrimSpace(req.Status)) + if req.Status != "PROCESSING" && req.Status != "RESOLVED" && req.Status != "CLOSED" { + fail(w, http.StatusBadRequest, 20001, "反馈状态无效") + return + } + if req.Status == "RESOLVED" && strings.TrimSpace(req.Reply) == "" { + fail(w, http.StatusBadRequest, 20001, "解决反馈时必须填写回复") + return + } + var userID int64 + if err = a.db.QueryRowContext(r.Context(), `SELECT user_id FROM user_feedback WHERE id=?`, id).Scan(&userID); err != nil { + fail(w, http.StatusNotFound, 30001, "反馈不存在") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE user_feedback SET status=?,reply_content=?,handled_by=?,handled_at=NOW(3) WHERE id=?`, req.Status, strings.TrimSpace(req.Reply), current(r).ID, id) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新反馈失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusNotFound, 30001, "反馈不存在") + return + } + if strings.TrimSpace(req.Reply) != "" { + a.notifyUser(r.Context(), userID, "system", "反馈处理结果", strings.TrimSpace(req.Reply), "feedback", id) + } + a.audit(r, "handle_feedback", "feedback", id, map[string]any{"status": req.Status}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminAccountClosures(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + page, size, offset := pagination(r) + status := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("status"))) + where, args := "", []any{} + if status != "" { + where = " WHERE c.status=?" + args = append(args, status) + } + var total int64 + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_account_closures c`+where, args...).Scan(&total); err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询注销申请失败") + return + } + queryArgs := append(append([]any{}, args...), size, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT c.user_id,u.public_id,p.nickname,p.avatar_url,c.reason,c.status,c.requested_at,c.execute_after,c.cancelled_at,c.completed_at FROM user_account_closures c JOIN users u ON u.id=c.user_id JOIN user_profiles p ON p.user_id=c.user_id`+where+` ORDER BY c.requested_at DESC LIMIT ? OFFSET ?`, queryArgs...) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询注销申请失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var userID int64 + var publicID, nickname, avatar, reason, itemStatus string + var requestedAt, executeAfter time.Time + var cancelledAt, completedAt sql.NullTime + if err = rows.Scan(&userID, &publicID, &nickname, &avatar, &reason, &itemStatus, &requestedAt, &executeAfter, &cancelledAt, &completedAt); err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取注销申请失败") + return + } + items = append(items, map[string]any{"userId": userID, "publicId": publicID, "nickname": nickname, "avatar": avatar, "reason": reason, "status": itemStatus, "requestedAt": requestedAt, "executeAfter": executeAfter, "cancelledAt": nullableTime(cancelledAt), "completedAt": nullableTime(completedAt)}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) + return + } + userID, err := pathID(r) + var req struct { + Action string `json:"action"` + Reason string `json:"reason"` + } + if err != nil || decode(r, &req) != nil || strings.ToLower(req.Action) != "cancel" || strings.TrimSpace(req.Reason) == "" { + fail(w, http.StatusBadRequest, 20001, "必须填写取消注销的原因") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE user_account_closures SET status='CANCELLED',cancelled_at=NOW(3) WHERE user_id=? AND status='PENDING'`, userID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "取消注销申请失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusBadRequest, 20001, "只有待执行的注销申请可以取消") + return + } + a.notifyUser(r.Context(), userID, "system", "注销申请已取消", strings.TrimSpace(req.Reason), "account_closure", nil) + a.audit(r, "cancel_account_closure", "user", userID, map[string]any{"reason": req.Reason}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminAppVersions(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,platform,version,build_number,force_update,download_url,release_notes,status,created_at FROM app_versions ORDER BY platform,build_number DESC`) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询客户端版本失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var platform, version, downloadURL, notes string + var build, force, status int + var createdAt time.Time + if rows.Scan(&id, &platform, &version, &build, &force, &downloadURL, ¬es, &status, &createdAt) == nil { + items = append(items, map[string]any{"id": id, "platform": platform, "version": version, "buildNumber": build, "forceUpdate": force == 1, "downloadUrl": downloadURL, "releaseNotes": notes, "status": status, "createdAt": createdAt}) + } + } + reply(w, map[string]any{"items": items, "total": len(items)}) + return + } + if r.Method == http.MethodDelete { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "版本编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `DELETE FROM app_versions WHERE id=?`, id) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "删除版本失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusNotFound, 30001, "版本不存在") + return + } + a.audit(r, "delete_app_version", "app_version", id, nil) + reply(w, map[string]bool{"success": true}) + return + } + var req struct { + ID int64 `json:"id"` + Platform string `json:"platform"` + Version string `json:"version"` + BuildNumber int `json:"buildNumber"` + ForceUpdate bool `json:"forceUpdate"` + DownloadURL string `json:"downloadUrl"` + ReleaseNotes string `json:"releaseNotes"` + Status int `json:"status"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "版本参数无效") + return + } + req.Platform = strings.ToLower(strings.TrimSpace(req.Platform)) + if req.Platform != "android" && req.Platform != "ios" && req.Platform != "h5" || strings.TrimSpace(req.Version) == "" || req.BuildNumber <= 0 || req.Status < 0 || req.Status > 1 { + fail(w, http.StatusBadRequest, 20001, "平台、版本号或构建号无效") + return + } + force := 0 + if req.ForceUpdate { + force = 1 + } + if r.Method == http.MethodPost { + result, err := a.db.ExecContext(r.Context(), `INSERT INTO app_versions(platform,version,build_number,force_update,download_url,release_notes,status) VALUES(?,?,?,?,?,?,?)`, req.Platform, strings.TrimSpace(req.Version), req.BuildNumber, force, strings.TrimSpace(req.DownloadURL), strings.TrimSpace(req.ReleaseNotes), req.Status) + if err != nil { + fail(w, http.StatusConflict, 20001, "该平台构建号已存在") + return + } + id, _ := result.LastInsertId() + a.audit(r, "create_app_version", "app_version", id, map[string]any{"platform": req.Platform, "version": req.Version, "buildNumber": req.BuildNumber}) + reply(w, map[string]any{"id": id}) + return + } + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "版本编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE app_versions SET platform=?,version=?,build_number=?,force_update=?,download_url=?,release_notes=?,status=? WHERE id=?`, req.Platform, strings.TrimSpace(req.Version), req.BuildNumber, force, strings.TrimSpace(req.DownloadURL), strings.TrimSpace(req.ReleaseNotes), req.Status, id) + if err != nil { + fail(w, http.StatusConflict, 20001, "该平台构建号已存在") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusNotFound, 30001, "版本不存在") + return + } + a.audit(r, "update_app_version", "app_version", id, map[string]any{"platform": req.Platform, "version": req.Version, "buildNumber": req.BuildNumber, "status": strconv.Itoa(req.Status)}) + reply(w, map[string]bool{"success": true}) +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_commerce_messages.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_commerce_messages.go new file mode 100644 index 0000000..1321f21 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_commerce_messages.go @@ -0,0 +1,284 @@ +package app + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +func (a *App) adminDeletePlan(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "套餐编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE membership_plans SET status=0,deleted_at=NOW(3) WHERE id=? AND deleted_at IS NULL`, id) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "删除套餐失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusNotFound, 30001, "套餐不存在或已删除") + return + } + var subscriptions, orders int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM subscriptions WHERE plan_id=?`, id).Scan(&subscriptions) + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM orders WHERE product_id=?`, id).Scan(&orders) + a.audit(r, "delete", "membership_plan", id, map[string]any{"mode": "soft", "subscriptions": subscriptions, "orders": orders}) + reply(w, map[string]any{"success": true, "archivedSubscriptions": subscriptions, "archivedOrders": orders}) +} + +type adminOrderUpdateRequest struct { + AmountCent *int `json:"amountCent"` + Channel *string `json:"channel"` + ProductID *int64 `json:"productId"` + Status *string `json:"status"` +} + +func validOrderStatus(status string) bool { + return status == "CREATED" || status == "PAID" || status == "REFUND_REQUESTED" || status == "REFUNDING" || status == "REFUNDED" || status == "CLOSED" +} + +func orderEntitlementActive(status string) bool { + return status == "PAID" || status == "REFUND_REQUESTED" || status == "REFUNDING" +} + +func (a *App) syncEditedOrderEntitlement(r *http.Request, tx *sql.Tx, orderID, userID, oldPlanID, newPlanID int64, oldStatus, newStatus string) error { + needsRevoke := orderEntitlementActive(oldStatus) && (!orderEntitlementActive(newStatus) || oldPlanID != newPlanID) + needsGrant := orderEntitlementActive(newStatus) && (!orderEntitlementActive(oldStatus) || oldPlanID != newPlanID) + if needsRevoke { + if _, err := tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1 AND source IN (?,?)`, userID, fmt.Sprintf("order:%d", orderID), fmt.Sprintf("admin_order:%d", orderID)); err != nil { + return err + } + } + if needsGrant { + var durationDays int + if err := tx.QueryRowContext(r.Context(), `SELECT duration_days FROM membership_plans WHERE id=?`, newPlanID).Scan(&durationDays); err != nil { + return err + } + if _, err := tx.ExecContext(r.Context(), `INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(?,?,?,1,NOW(3),DATE_ADD(NOW(3),INTERVAL ? DAY))`, userID, newPlanID, fmt.Sprintf("admin_order:%d", orderID), durationDays); err != nil { + return err + } + } + if needsRevoke || needsGrant { + return a.recomputeMembershipTx(r.Context(), tx, userID) + } + return nil +} + +func (a *App) adminUpdateOrder(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + var req adminOrderUpdateRequest + if err != nil || decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "订单信息格式无效") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存订单失败") + return + } + defer func() { _ = tx.Rollback() }() + var userID, oldPlanID int64 + var oldAmount int + var oldStatus, oldChannel, providerOrderNo string + if err = tx.QueryRowContext(r.Context(), `SELECT user_id,product_id,amount_cent,status,channel,provider_order_no FROM orders WHERE id=? AND deleted_at IS NULL FOR UPDATE`, id).Scan(&userID, &oldPlanID, &oldAmount, &oldStatus, &oldChannel, &providerOrderNo); err != nil { + fail(w, http.StatusNotFound, 30001, "订单不存在或已删除") + return + } + newPlanID, newAmount, newStatus, newChannel := oldPlanID, oldAmount, oldStatus, oldChannel + if req.ProductID != nil { + newPlanID = *req.ProductID + } + if req.AmountCent != nil { + newAmount = *req.AmountCent + } + if req.Status != nil { + newStatus = strings.ToUpper(strings.TrimSpace(*req.Status)) + } + if req.Channel != nil { + newChannel = strings.TrimSpace(*req.Channel) + } + if newPlanID <= 0 || newAmount < 0 || !validOrderStatus(newStatus) || len(newChannel) > 30 { + fail(w, http.StatusBadRequest, 20001, "订单套餐、金额、渠道或状态无效") + return + } + if a.configPlain(r.Context(), "payment.mode", "sandbox") == "live" { + financialFieldsChanged := newPlanID != oldPlanID || newAmount != oldAmount || newChannel != oldChannel + if financialFieldsChanged && (oldStatus != "CREATED" || providerOrderNo != "") { + fail(w, http.StatusBadRequest, 20001, "生产订单创建支付流水后禁止修改套餐、金额或渠道") + return + } + if newStatus != oldStatus && !(oldStatus == "CREATED" && newStatus == "CLOSED") { + fail(w, http.StatusBadRequest, 20001, "生产订单的支付与退款状态只能由已验签回调更新") + return + } + } + var planExists int + planQuery := `SELECT COUNT(*) FROM membership_plans WHERE id=?` + if newStatus == "PAID" || newPlanID != oldPlanID { + planQuery += ` AND deleted_at IS NULL` + } + if err = tx.QueryRowContext(r.Context(), planQuery, newPlanID).Scan(&planExists); err != nil || planExists == 0 { + fail(w, http.StatusBadRequest, 20001, "选择的会员套餐不存在") + return + } + if err = a.syncEditedOrderEntitlement(r, tx, id, userID, oldPlanID, newPlanID, oldStatus, newStatus); err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE orders SET product_id=?,amount_cent=?,status=?,channel=?,paid_at=IF(?='PAID',COALESCE(paid_at,NOW(3)),paid_at) WHERE id=?`, newPlanID, newAmount, newStatus, newChannel, newStatus, id) + } + if err != nil || tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "保存订单及会员权益失败") + return + } + a.audit(r, "update", "order", id, map[string]any{"previousStatus": oldStatus, "status": newStatus, "previousPlanId": oldPlanID, "planId": newPlanID, "amountCent": newAmount, "channel": newChannel}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminDeleteOrder(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "订单编号无效") + return + } + var userID int64 + var status string + if err = a.db.QueryRowContext(r.Context(), `SELECT user_id,status FROM orders WHERE id=? AND deleted_at IS NULL`, id).Scan(&userID, &status); err != nil { + fail(w, http.StatusNotFound, 30001, "订单不存在或已删除") + return + } + if status == "REFUNDING" { + fail(w, http.StatusBadRequest, 20001, "退款处理中的订单不能删除") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE orders SET deleted_at=NOW(3) WHERE id=? AND deleted_at IS NULL`, id) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "删除订单失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusNotFound, 30001, "订单不存在或已删除") + return + } + a.audit(r, "delete", "order", id, map[string]any{"mode": "soft", "status": status, "userId": userID, "membershipPreserved": status == "PAID"}) + reply(w, map[string]any{"success": true, "membershipPreserved": status == "PAID"}) +} + +func (a *App) adminMessages(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + keyword := strings.TrimSpace(r.URL.Query().Get("keyword")) + conversationID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("conversationId")), 10, 64) + messageType, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("type"))) + where := ` WHERE 1=1` + args := []any{} + if conversationID > 0 { + where += ` AND m.conversation_id=?` + args = append(args, conversationID) + } + if messageType > 0 { + where += ` AND m.message_type=?` + args = append(args, messageType) + } + if keyword != "" { + where += ` AND (CONVERT(m.client_msg_id USING utf8mb4) LIKE ? OR CAST(m.body AS CHAR CHARACTER SET utf8mb4) LIKE ? OR sp.nickname LIKE ? OR su.public_id LIKE ? OR EXISTS (SELECT 1 FROM im_conversation_members kcm JOIN users ku ON ku.id=kcm.user_id JOIN user_profiles kp ON kp.user_id=kcm.user_id WHERE kcm.conversation_id=m.conversation_id AND kcm.user_id<>m.sender_id AND (kp.nickname LIKE ? OR ku.public_id LIKE ?)))` + like := "%" + keyword + "%" + args = append(args, like, like, like, like, like, like) + } + base := ` FROM im_messages m JOIN users su ON su.id=m.sender_id JOIN user_profiles sp ON sp.user_id=m.sender_id` + var total int64 + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*)`+base+where, args...).Scan(&total); err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询消息记录失败") + return + } + queryArgs := append(append([]any{}, args...), size, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,m.conversation_id,m.seq,m.sender_id,su.public_id,sp.nickname,sp.avatar_url,m.client_msg_id,m.message_type,m.body,m.moderation_status,m.recalled_at,m.admin_removed_at,m.admin_removed_by,m.admin_remove_reason,m.created_at,COALESCE((SELECT GROUP_CONCAT(CONCAT(kp.nickname,' (',ku.public_id,')') ORDER BY kp.nickname SEPARATOR '、') FROM im_conversation_members kcm JOIN users ku ON ku.id=kcm.user_id JOIN user_profiles kp ON kp.user_id=kcm.user_id WHERE kcm.conversation_id=m.conversation_id AND kcm.user_id<>m.sender_id),'')`+base+where+` ORDER BY m.created_at DESC,m.id DESC LIMIT ? OFFSET ?`, queryArgs...) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询消息记录失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, convID, seq, senderID int64 + var publicID, nickname, avatar, clientMsgID, recipients, adminRemoveReason string + var typ, moderation int + var body []byte + var recalledAt, adminRemovedAt sql.NullTime + var adminRemovedBy sql.NullInt64 + var createdAt time.Time + if rows.Scan(&id, &convID, &seq, &senderID, &publicID, &nickname, &avatar, &clientMsgID, &typ, &body, &moderation, &recalledAt, &adminRemovedAt, &adminRemovedBy, &adminRemoveReason, &createdAt, &recipients) != nil { + continue + } + var content any + if json.Unmarshal(body, &content) != nil { + content = string(body) + } + items = append(items, map[string]any{"id": id, "conversationId": convID, "seq": seq, "senderId": senderID, "senderPublicId": publicID, "senderNickname": nickname, "senderAvatar": avatar, "recipients": recipients, "clientMsgId": clientMsgID, "type": typ, "content": content, "moderationStatus": moderation, "recalledAt": nullableTime(recalledAt), "adminRemovedAt": nullableTime(adminRemovedAt), "adminRemovedBy": nullableInt64(adminRemovedBy), "adminRemoveReason": adminRemoveReason, "createdAt": createdAt}) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} + +func (a *App) adminModerateMessage(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "消息编号无效") + return + } + var req struct { + Action string `json:"action"` + Reason string `json:"reason"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "消息处置参数无效") + return + } + req.Action = strings.ToUpper(strings.TrimSpace(req.Action)) + req.Reason = strings.TrimSpace(req.Reason) + if req.Action != "REMOVE" && req.Action != "RESTORE" { + fail(w, http.StatusBadRequest, 20001, "消息处置动作无效") + return + } + if req.Action == "REMOVE" && (len([]rune(req.Reason)) < 2 || len([]rune(req.Reason)) > 500) { + fail(w, http.StatusBadRequest, 20001, "请填写 2-500 字下架原因") + return + } + var conversationID, seq int64 + if err = a.db.QueryRowContext(r.Context(), `SELECT conversation_id,seq FROM im_messages WHERE id=?`, id).Scan(&conversationID, &seq); err != nil { + fail(w, http.StatusNotFound, 30001, "消息不存在") + return + } + var result sql.Result + command := "MESSAGE_RECALLED" + if req.Action == "REMOVE" { + result, err = a.db.ExecContext(r.Context(), `UPDATE im_messages SET moderation_status=0,admin_removed_at=NOW(3),admin_removed_by=?,admin_remove_reason=? WHERE id=? AND admin_removed_at IS NULL`, current(r).ID, req.Reason, id) + } else { + command = "MESSAGE_RESTORED" + result, err = a.db.ExecContext(r.Context(), `UPDATE im_messages SET moderation_status=1,admin_removed_at=NULL,admin_removed_by=NULL,admin_remove_reason='' WHERE id=? AND admin_removed_at IS NOT NULL`, id) + } + var affected int64 + if err == nil { + affected, _ = result.RowsAffected() + } + if err != nil || affected == 0 { + fail(w, http.StatusConflict, 20001, "消息状态已发生变化,请刷新后重试") + return + } + rows, _ := a.db.QueryContext(r.Context(), `SELECT user_id FROM im_conversation_members WHERE conversation_id=? AND status=1`, conversationID) + members := []int64{} + if rows != nil { + for rows.Next() { + var memberID int64 + _ = rows.Scan(&memberID) + members = append(members, memberID) + } + _ = rows.Close() + } + a.hub.broadcast(members, map[string]any{"command": command, "data": map[string]any{"id": id, "conversationId": conversationID, "seq": seq}}) + a.audit(r, strings.ToLower(req.Action)+"_message", "message", id, map[string]any{"reason": req.Reason}) + reply(w, map[string]bool{"success": true}) +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_create_user.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_create_user.go new file mode 100644 index 0000000..fb21c8e --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_create_user.go @@ -0,0 +1,103 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-sql-driver/mysql" +) + +type adminCreateUserRequest struct { + Phone string `json:"phone"` + Password string `json:"password"` + Nickname string `json:"nickname"` + Gender int `json:"gender"` + City string `json:"city"` + Bio string `json:"bio"` +} + +// Administrator provisioning uses the same credentials as public registration, +// but never creates a login session or marks the phone/identity as verified. +func (a *App) adminCreateUser(w http.ResponseWriter, r *http.Request) { + var req adminCreateUserRequest + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "用户资料格式不正确") + return + } + req.Phone = strings.TrimSpace(req.Phone) + req.Nickname = strings.TrimSpace(req.Nickname) + req.City = strings.TrimSpace(req.City) + req.Bio = strings.TrimSpace(req.Bio) + if !validPhone(req.Phone) { + fail(w, http.StatusBadRequest, 20001, "请输入有效的中国大陆手机号") + return + } + if !validUserPassword(req.Password) { + fail(w, http.StatusBadRequest, 20001, "请输入初始密码") + return + } + if req.Nickname == "" || len([]rune(req.Nickname)) > 50 || req.Gender < 0 || req.Gender > 2 || len([]rune(req.City)) > 50 || len([]rune(req.Bio)) > 500 { + fail(w, http.StatusBadRequest, 20001, "昵称须为 1–50 字,城市最多 50 字,简介最多 500 字,性别须为有效选项") + return + } + passwordHash, err := hashPassword(req.Password) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建用户失败") + return + } + phoneCipher, err := a.encryptPhone(req.Phone) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "加密手机号失败") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建用户失败") + return + } + defer func() { _ = tx.Rollback() }() + publicID := fmt.Sprintf("XY%d%s", time.Now().UnixMilli(), randomToken()[:5]) + result, err := tx.ExecContext(r.Context(), `INSERT INTO users (public_id,country_code,phone_hash,phone_cipher,password_hash) VALUES (?,'+86',?,?,?)`, publicID, phoneHash(req.Phone), phoneCipher, passwordHash) + if err != nil { + var mysqlErr *mysql.MySQLError + if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 && strings.Contains(mysqlErr.Message, "uk_users_phone_hash") { + fail(w, http.StatusConflict, 20001, "该手机号已被使用,请勿重复创建") + } else { + fail(w, http.StatusInternalServerError, 50001, "创建用户失败,请稍后重试") + } + return + } + userID, err := result.LastInsertId() + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建用户失败") + return + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_profiles (user_id,nickname,gender,city_name,bio,profile_score) VALUES (?,?,?,?,?,30)`, userID, req.Nickname, req.Gender, req.City, req.Bio); err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建用户资料失败") + return + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_privacy_settings (user_id) VALUES (?)`, userID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "初始化隐私设置失败") + return + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_notification_settings (user_id) VALUES (?)`, userID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "初始化通知设置失败") + return + } + // Audit and account creation succeed together. Do not store the request body: + // it contains the initial password and the full phone number. + auditData, _ := json.Marshal(map[string]any{"publicId": publicID, "nickname": req.Nickname, "source": "admin"}) + if _, err = tx.ExecContext(r.Context(), `INSERT INTO admin_audit_logs(admin_user_id,action,target_type,target_id,request_data,ip) VALUES (?,'create_user','user',?,?,?)`, current(r).ID, userID, auditData, clientIP(r)); err != nil { + fail(w, http.StatusInternalServerError, 50001, "记录创建日志失败,用户未创建") + return + } + if err = tx.Commit(); err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建用户失败") + return + } + reply(w, map[string]any{"id": userID, "publicId": publicID}) +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_create_user_test.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_create_user_test.go new file mode 100644 index 0000000..9883ade --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_create_user_test.go @@ -0,0 +1,317 @@ +package app + +import ( + "bytes" + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-sql-driver/mysql" +) + +// This transactional fixture exercises the real HTTP handlers without needing +// production credentials. Unexpected SQL is an error, including session writes +// during administrator provisioning. +type provisionedUser struct { + publicID, passwordHash, nickname, city, bio string + phoneHash, phoneCipher []byte + gender int64 + privacy, notification bool + audit []byte + sessions int +} + +type provisioningDB struct { + t *testing.T + user, pending *provisionedUser + allowed, inTx bool + failAt string + failure error + begins, rollbacks int +} + +type provisioningConnector struct{ store *provisioningDB } + +func (c provisioningConnector) Connect(context.Context) (driver.Conn, error) { return c.store, nil } +func (provisioningConnector) Driver() driver.Driver { return oauthTestDriver{} } +func (*provisioningDB) Prepare(string) (driver.Stmt, error) { + return nil, fmt.Errorf("unexpected prepare") +} +func (*provisioningDB) Close() error { return nil } +func (s *provisioningDB) Begin() (driver.Tx, error) { + s.begins++ + s.inTx = true + s.pending = nil + if s.user != nil { + copy := *s.user + s.pending = © + } + return s, nil +} +func (s *provisioningDB) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) { + return s.Begin() +} +func (s *provisioningDB) Commit() error { + s.user, s.pending, s.inTx = s.pending, nil, false + return nil +} +func (s *provisioningDB) Rollback() error { + s.pending, s.inTx = nil, false + s.rollbacks++ + return nil +} + +type provisioningInsertResult struct{} + +func (provisioningInsertResult) LastInsertId() (int64, error) { return 42, nil } +func (provisioningInsertResult) RowsAffected() (int64, error) { return 1, nil } + +func (s *provisioningDB) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + if s.failAt != "" && strings.Contains(query, s.failAt) { + return nil, s.failure + } + if strings.Contains(query, "api_rate_limits") { + return driver.RowsAffected(1), nil + } + if !s.inTx { + s.t.Errorf("account mutation outside transaction: %s", query) + return nil, fmt.Errorf("no transaction") + } + switch { + case strings.HasPrefix(query, "INSERT INTO users "): + if s.pending != nil && bytes.Equal(s.pending.phoneHash, args[1].Value.([]byte)) { + return nil, &mysql.MySQLError{Number: 1062, Message: "Duplicate entry for key 'users.uk_users_phone_hash'"} + } + s.pending = &provisionedUser{publicID: args[0].Value.(string), phoneHash: args[1].Value.([]byte), phoneCipher: args[2].Value.([]byte), passwordHash: args[3].Value.(string)} + return provisioningInsertResult{}, nil + case strings.HasPrefix(query, "INSERT INTO user_profiles "): + s.pending.nickname, s.pending.gender = args[1].Value.(string), args[2].Value.(int64) + s.pending.city, s.pending.bio = args[3].Value.(string), args[4].Value.(string) + case strings.HasPrefix(query, "INSERT INTO user_privacy_settings "): + s.pending.privacy = true + case strings.HasPrefix(query, "INSERT INTO user_notification_settings "): + s.pending.notification = true + case strings.HasPrefix(query, "INSERT INTO admin_audit_logs"): + if args[0].Value != int64(7) || args[1].Value != int64(42) { + s.t.Error("audit must identify both the administrator and created user") + } + s.pending.audit = args[2].Value.([]byte) + case strings.HasPrefix(query, "INSERT INTO user_sessions "): + s.pending.sessions++ + case strings.HasPrefix(query, "UPDATE user_sessions "), strings.HasPrefix(query, "INSERT INTO user_devices"), strings.HasPrefix(query, "UPDATE user_profiles SET last_active_at"): + default: + s.t.Errorf("unexpected exec: %s", query) + return nil, fmt.Errorf("unexpected exec") + } + return driver.RowsAffected(1), nil +} + +func (s *provisioningDB) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { + switch { + case strings.Contains(query, "SELECT status,token_version FROM admin_users"): + return oauthRow(int64(1), int64(0)), nil + case strings.Contains(query, "SELECT token_version FROM"): + return oauthRow(int64(0)), nil + case strings.Contains(query, "FROM admin_user_roles"): + allowed := int64(0) + if s.allowed && args[1].Value == "users:create" { + allowed = 1 + } + return oauthRow(allowed), nil + case strings.Contains(query, "SELECT hits FROM api_rate_limits"): + return oauthRow(int64(1)), nil + case strings.HasPrefix(query, "SELECT u.id,u.password_hash,u.status,p.nickname"): + if s.user == nil || !bytes.Equal(s.user.phoneHash, args[0].Value.([]byte)) { + return &oauthTestRows{columns: []string{"id", "password_hash", "status", "nickname"}}, nil + } + return oauthRow(int64(42), s.user.passwordHash, int64(1), s.user.nickname), nil + case strings.HasPrefix(query, "SELECT COUNT(*) FROM users u JOIN user_profiles"): + return oauthRow(int64(1)), nil + case strings.HasPrefix(query, "SELECT u.id,u.public_id,u.phone_cipher"): + u := s.user + return oauthRow(int64(42), u.publicID, u.phoneCipher, int64(1), int64(0), time.Now(), false, "", u.nickname, "", u.gender, u.city, int64(0), int64(0), nil, "UNVERIFIED", nil), nil + default: + s.t.Errorf("unexpected query: %s", query) + return nil, fmt.Errorf("unexpected query") + } +} + +func provisioningApp(t *testing.T) (*App, *provisioningDB, http.HandlerFunc, string) { + t.Helper() + store := &provisioningDB{t: t, allowed: true} + db := sql.OpenDB(provisioningConnector{store}) + t.Cleanup(func() { _ = db.Close() }) + a := &App{db: db, config: Config{JWTSecret: "test-provisioning-secret", Environment: "development"}} + token, err := a.token(7, "admin", "tester", time.Hour) + if err != nil { + t.Fatal(err) + } + for _, route := range a.adminRoutes() { + if route.Method == http.MethodPost && route.Path == "/admin/v1/users" { + return a, store, route.Handler, token + } + } + t.Fatal("admin create user route is not registered") + return nil, nil, nil, "" +} + +func provisionRequest(handler http.HandlerFunc, token string, payload any) *httptest.ResponseRecorder { + body, _ := json.Marshal(payload) + r := httptest.NewRequest(http.MethodPost, "/admin/v1/users", bytes.NewReader(body)) + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + handler(w, r) + return w +} + +func validProvisionPayload() map[string]any { + return map[string]any{"phone": "13800138000", "password": "Password123!", "nickname": " 管理员创建用户 ", "gender": 1, "city": " 北京 ", "bio": " 简介 "} +} + +func TestAdminCreateUserAndPasswordLogin(t *testing.T) { + a, store, handler, token := provisioningApp(t) + w := provisionRequest(handler, token, validProvisionPayload()) + if w.Code != http.StatusOK || store.user == nil { + t.Fatalf("create failed: %d %s", w.Code, w.Body.String()) + } + u := store.user + if !u.privacy || !u.notification || len(u.audit) == 0 || u.sessions != 0 { + t.Fatal("account must have default settings and an audit, but no login session") + } + if u.nickname != "管理员创建用户" || u.city != "北京" || u.bio != "简介" { + t.Fatal("profile whitespace was not normalized") + } + if len(u.publicID) > 20 || !strings.HasPrefix(u.publicID, "XY") { + t.Fatal("invalid public ID") + } + phone, err := a.decryptPhone(u.phoneCipher) + if err != nil || phone != "13800138000" || bytes.Contains(u.phoneCipher, []byte(phone)) { + t.Fatal("phone was not encrypted correctly") + } + if !checkPassword(u.passwordHash, "Password123!") || u.passwordHash == "Password123!" { + t.Fatal("password must be bcrypt hashed") + } + for _, sensitive := range []string{"13800138000", "Password123!", "accessToken", "refreshToken"} { + if strings.Contains(w.Body.String(), sensitive) || bytes.Contains(u.audit, []byte(sensitive)) { + t.Fatalf("creation leaked %s", sensitive) + } + } + list := httptest.NewRecorder() + a.adminUsers(list, httptest.NewRequest(http.MethodGet, "/admin/v1/users?keyword="+u.publicID, nil)) + if list.Code != 200 || !strings.Contains(list.Body.String(), u.publicID) || !strings.Contains(list.Body.String(), `"phone":"138****8000"`) { + t.Fatalf("new account missing from list: %s", list.Body.String()) + } + wrong := provisionRequest(a.loginPassword, "", map[string]any{"phone": phone, "password": "wrong-password"}) + if wrong.Code != http.StatusUnauthorized || store.user.sessions != 0 { + t.Fatal("incorrect initial password was accepted") + } + login := provisionRequest(a.loginPassword, "", map[string]any{"phone": phone, "password": "Password123!", "deviceId": "app-test"}) + var result struct { + Data struct { + AccessToken string `json:"accessToken"` + } `json:"data"` + } + if login.Code != http.StatusOK || json.Unmarshal(login.Body.Bytes(), &result) != nil || store.user.sessions != 1 { + t.Fatalf("password login failed: %d %s", login.Code, login.Body.String()) + } + who, err := a.parseToken(result.Data.AccessToken) + if err != nil || who.ID != 42 || who.Role != "user" { + t.Fatal("login did not issue a valid user token") + } +} + +func TestAdminCreateUserPermissions(t *testing.T) { + a, store, handler, token := provisioningApp(t) + store.allowed = false + if w := provisionRequest(handler, token, validProvisionPayload()); w.Code != http.StatusForbidden { + t.Fatalf("missing create permission accepted: %d", w.Code) + } + if w := provisionRequest(handler, "", validProvisionPayload()); w.Code != http.StatusUnauthorized { + t.Fatal("anonymous creation accepted") + } + userToken, _ := a.token(42, "user", "user", time.Hour) + if w := provisionRequest(handler, userToken, validProvisionPayload()); w.Code != http.StatusUnauthorized { + t.Fatal("client token accepted") + } + if store.begins != 0 { + t.Fatal("unauthorized caller reached account creation") + } +} + +func TestAdminCreateUserValidation(t *testing.T) { + cases := []struct { + key string + value any + }{ + {"phone", "12345678901"}, {"phone", ""}, {"password", ""}, + {"nickname", " "}, {"nickname", strings.Repeat("名", 51)}, + {"gender", 3}, {"gender", -1}, {"gender", 1.5}, {"city", strings.Repeat("城", 51)}, {"bio", strings.Repeat("文", 501)}, + {"isTest", true}, {"status", 3}, {"vip", true}, + } + for i, tc := range cases { + t.Run(fmt.Sprintf("%s-%d", tc.key, i), func(t *testing.T) { + _, store, handler, token := provisioningApp(t) + payload := validProvisionPayload() + payload[tc.key] = tc.value + w := provisionRequest(handler, token, payload) + if w.Code != http.StatusBadRequest || store.begins != 0 { + t.Fatalf("invalid input accepted: %d %s", w.Code, w.Body.String()) + } + }) + } +} + +func TestAdminCreateUserDuplicatePhonePreservesAccount(t *testing.T) { + _, store, handler, token := provisioningApp(t) + if w := provisionRequest(handler, token, validProvisionPayload()); w.Code != http.StatusOK { + t.Fatal(w.Body.String()) + } + original := store.user + payload := validProvisionPayload() + payload["nickname"], payload["password"], payload["phone"] = "覆盖用户", "Different123", " 13800138000 " + w := provisionRequest(handler, token, payload) + if w.Code != http.StatusConflict || store.user != original || store.rollbacks != 1 { + t.Fatalf("duplicate did not preserve original account: %d %s", w.Code, w.Body.String()) + } +} + +func TestAdminCreatedUserCanLoginWithSimpleOrLongPassword(t *testing.T) { + for _, password := range []string{"1", "letters", "中文", strings.Repeat("长密码", 30)} { + a, store, handler, token := provisioningApp(t) + payload := validProvisionPayload() + payload["password"] = password + created := provisionRequest(handler, token, payload) + if created.Code != http.StatusOK { + t.Fatalf("password rejected: %d %s", created.Code, created.Body.String()) + } + login := provisionRequest(a.loginPassword, "", map[string]any{"phone": payload["phone"], "password": password}) + if login.Code != http.StatusOK || store.user.sessions != 1 { + t.Fatalf("new password cannot login: %d %s", login.Code, login.Body.String()) + } + } +} + +func TestAdminCreateUserRollsBackEveryFailedWrite(t *testing.T) { + for _, table := range []string{"users", "user_profiles", "user_privacy_settings", "user_notification_settings", "admin_audit_logs"} { + t.Run(table, func(t *testing.T) { + _, store, handler, token := provisioningApp(t) + store.failAt, store.failure = "INSERT INTO "+table, fmt.Errorf("database unavailable") + w := provisionRequest(handler, token, validProvisionPayload()) + if w.Code != http.StatusInternalServerError || store.user != nil || store.rollbacks != 1 { + t.Fatalf("failed %s write left partial account: %d %s", table, w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "手机号已被使用") { + t.Fatal("database error misreported as duplicate phone") + } + }) + } +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_oauth.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_oauth.go new file mode 100644 index 0000000..c2e3324 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_oauth.go @@ -0,0 +1,772 @@ +package app + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + adminOAuthStateTTL = 10 * time.Minute + adminOAuthCodeTTL = 5 * time.Minute + adminOAuthBodyMax = 1 << 20 +) + +var adminOAuthProviderNames = map[string]string{ + "wechat": "微信", + "qq": "QQ", + "github": "GitHub", + "google": "Google", +} + +var adminOAuthAllowedHosts = map[string]map[string]bool{ + "wechat": {"open.weixin.qq.com": true, "api.weixin.qq.com": true}, + "qq": {"graph.qq.com": true}, + "github": {"github.com": true, "api.github.com": true}, + "google": {"accounts.google.com": true, "oauth2.googleapis.com": true, "openidconnect.googleapis.com": true}, +} + +type adminOAuthProvider struct { + Code string + Name string + ClientID string + ClientSecret string + AuthorizationURL string + TokenURL string + OpenIDURL string + UserInfoURL string + Scope string + RedirectURI string +} + +type adminOAuthIdentity struct { + Subject string + Email string + DisplayName string + AvatarURL string +} + +type adminOAuthLoginCode struct { + Provider string + Subject string + Email string + DisplayName string + AvatarURL string + AdminUserID sql.NullInt64 +} + +func oauthHash(value string) []byte { + hash := sha256.Sum256([]byte(value)) + return hash[:] +} + +func pkceChallenge(verifier string) string { + hash := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(hash[:]) +} + +func (a *App) adminOAuthProvider(ctx context.Context, code string) (adminOAuthProvider, error) { + name, ok := adminOAuthProviderNames[code] + if !ok { + return adminOAuthProvider{}, errors.New("不支持的第三方登录渠道") + } + prefix := "oauth." + code + "." + provider := adminOAuthProvider{ + Code: code, + Name: name, + ClientID: strings.TrimSpace(a.configPlain(ctx, prefix+"client_id", "")), + ClientSecret: strings.TrimSpace(a.configPlain(ctx, prefix+"client_secret", "")), + AuthorizationURL: strings.TrimSpace(a.configPlain(ctx, prefix+"authorization_url", "")), + TokenURL: strings.TrimSpace(a.configPlain(ctx, prefix+"token_url", "")), + OpenIDURL: strings.TrimSpace(a.configPlain(ctx, prefix+"openid_url", "")), + UserInfoURL: strings.TrimSpace(a.configPlain(ctx, prefix+"userinfo_url", "")), + Scope: strings.TrimSpace(a.configPlain(ctx, prefix+"scope", "")), + RedirectURI: strings.TrimSpace(a.configPlain(ctx, prefix+"redirect_uri", "")), + } + if provider.ClientID == "" || provider.ClientSecret == "" || provider.AuthorizationURL == "" || provider.TokenURL == "" || provider.UserInfoURL == "" || provider.Scope == "" || provider.RedirectURI == "" { + return adminOAuthProvider{}, fmt.Errorf("%s登录配置不完整", name) + } + if code == "qq" && provider.OpenIDURL == "" { + return adminOAuthProvider{}, errors.New("QQ 登录 OpenID 地址未配置") + } + for label, raw := range map[string]string{ + "授权地址": provider.AuthorizationURL, + "令牌地址": provider.TokenURL, + "用户信息地址": provider.UserInfoURL, + } { + if err := validateAdminOAuthEndpoint(code, raw); err != nil { + return adminOAuthProvider{}, fmt.Errorf("%s%s无效:%w", name, label, err) + } + } + if provider.OpenIDURL != "" { + if err := validateAdminOAuthEndpoint(code, provider.OpenIDURL); err != nil { + return adminOAuthProvider{}, fmt.Errorf("%s OpenID 地址无效:%w", name, err) + } + } + if err := a.validateAdminOAuthRedirectURL(provider.RedirectURI); err != nil { + return adminOAuthProvider{}, fmt.Errorf("%s回调地址无效:%w", name, err) + } + return provider, nil +} + +func validateAdminOAuthEndpoint(provider, raw string) error { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil { + return errors.New("必须是合法的 HTTPS 地址") + } + if !adminOAuthAllowedHosts[provider][strings.ToLower(parsed.Hostname())] { + return errors.New("域名不在该渠道的官方白名单内") + } + if port := parsed.Port(); port != "" && port != "443" { + return errors.New("仅允许使用标准 HTTPS 端口") + } + return nil +} + +func (a *App) validateAdminOAuthRedirectURL(raw string) error { + parsed, err := url.Parse(raw) + if err != nil || parsed.Hostname() == "" || parsed.User != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") { + return errors.New("必须是合法的 HTTP(S) 地址") + } + if parsed.Scheme == "http" { + host := strings.ToLower(parsed.Hostname()) + if a.config.Environment == "production" || (host != "localhost" && host != "127.0.0.1" && host != "::1") { + return errors.New("仅本地开发允许 HTTP,生产环境必须使用 HTTPS") + } + } + return nil +} + +func (a *App) adminOAuthFrontendURL(ctx context.Context) (string, error) { + raw := strings.TrimSpace(a.configPlain(ctx, "oauth.admin.frontend_callback_url", "")) + if raw == "" { + return "", errors.New("管理端登录结果页未配置") + } + if err := a.validateAdminOAuthRedirectURL(raw); err != nil { + return "", fmt.Errorf("管理端登录结果页无效:%w", err) + } + return raw, nil +} + +func (a *App) enabledAdminOAuthProviders(ctx context.Context) ([]adminOAuthProvider, error) { + providers := make([]adminOAuthProvider, 0, len(adminOAuthProviderNames)) + for _, code := range []string{"wechat", "qq", "github", "google"} { + if !a.configBool(ctx, "oauth."+code+".enabled", false) { + continue + } + provider, err := a.adminOAuthProvider(ctx, code) + if err != nil { + return nil, err + } + providers = append(providers, provider) + } + return providers, nil +} + +func (a *App) oauthConfigurationReady(ctx context.Context) bool { + adminProviders, err := a.enabledAdminOAuthProviders(ctx) + if err != nil { + return false + } + userProviders, err := a.enabledUserOAuthProviders(ctx) + if err != nil { + return false + } + if len(adminProviders) > 0 { + if _, err := a.adminOAuthFrontendURL(ctx); err != nil { + return false + } + } + if len(userProviders) > 0 { + if _, err := a.userOAuthFrontendURL(ctx); err != nil { + return false + } + } + _, err = a.enabledAppOAuthProviders(ctx) + return err == nil +} + +func (a *App) validateAdminOAuthConfigValues(ctx context.Context, values map[string]string, clearSecrets map[string]bool) error { + value := func(key string) string { + if clearSecrets[key] { + return "" + } + if candidate, exists := values[key]; exists { + if candidate != "" || !strings.HasSuffix(key, "client_secret") { + return strings.TrimSpace(candidate) + } + } + return strings.TrimSpace(a.configPlain(ctx, key, "")) + } + for _, client := range []struct{ prefix, callback, name string }{ + {"oauth.", "oauth.admin.frontend_callback_url", "管理端"}, + {"oauth.user.", "oauth.user.frontend_callback_url", "H5"}, + } { + for _, code := range []string{"wechat", "qq", "github", "google"} { + if value(client.prefix+code+".enabled") == "true" { + if err := a.validateAdminOAuthRedirectURL(value(client.callback)); err != nil { + return fmt.Errorf("%s 登录结果页无效:%w", client.name, err) + } + break + } + } + } + for _, code := range []string{"wechat", "qq", "github", "google"} { + adminEnabled := strings.ToLower(value("oauth."+code+".enabled")) == "true" + userEnabled := strings.ToLower(value("oauth.user."+code+".enabled")) == "true" + appEnabled := value("oauth.app."+code+".enabled") == "true" + if appEnabled { + if code == "github" { + if value("oauth.app.frontend_callback_url") != appOAuthCallbackURL { + return errors.New("App 回调地址必须为 " + appOAuthCallbackURL) + } + } else if err := validateNativeOAuthConfig(code, value); err != nil { + return err + } + } + if !adminEnabled && !userEnabled && !(appEnabled && code == "github") { + continue + } + prefix := "oauth." + code + "." + required := []string{"client_id", "client_secret", "authorization_url", "token_url", "userinfo_url", "scope", "redirect_uri"} + if code == "qq" { + required = append(required, "openid_url") + } + for _, suffix := range required { + if value(prefix+suffix) == "" { + return fmt.Errorf("%s登录的%s不能为空", adminOAuthProviderNames[code], suffix) + } + } + for label, raw := range map[string]string{ + "授权地址": value(prefix + "authorization_url"), + "令牌地址": value(prefix + "token_url"), + "用户信息地址": value(prefix + "userinfo_url"), + } { + if err := validateAdminOAuthEndpoint(code, raw); err != nil { + return fmt.Errorf("%s%s无效:%w", adminOAuthProviderNames[code], label, err) + } + } + if code == "qq" { + if err := validateAdminOAuthEndpoint(code, value(prefix+"openid_url")); err != nil { + return fmt.Errorf("QQ OpenID 地址无效:%w", err) + } + } + if err := a.validateAdminOAuthRedirectURL(value(prefix + "redirect_uri")); err != nil { + return fmt.Errorf("%s回调地址无效:%w", adminOAuthProviderNames[code], err) + } + } + return nil +} + +func (a *App) adminOAuthProviders(w http.ResponseWriter, r *http.Request) { + items := make([]map[string]string, 0, len(adminOAuthProviderNames)) + for _, code := range []string{"wechat", "qq", "github", "google"} { + if !a.configBool(r.Context(), "oauth."+code+".enabled", false) { + continue + } + provider, err := a.adminOAuthProvider(r.Context(), code) + if err != nil { + // A broken channel must not hide other correctly configured channels. + continue + } + items = append(items, map[string]string{"code": provider.Code, "name": provider.Name}) + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) adminOAuthStart(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "请选择第三方登录渠道") + return + } + req.Provider = strings.ToLower(strings.TrimSpace(req.Provider)) + if !a.rateLimit(w, r, "admin_oauth_start", clientIP(r), 30, 10*time.Minute) { + return + } + if !a.configBool(r.Context(), "oauth."+req.Provider+".enabled", false) { + fail(w, http.StatusBadRequest, 20001, "该登录方式未启用") + return + } + provider, err := a.adminOAuthProvider(r.Context(), req.Provider) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "该登录方式配置不完整") + return + } + state := randomToken() + verifier := "" + if provider.Code == "github" || provider.Code == "google" { + verifier = randomToken() + randomToken() + } + _, err = a.db.ExecContext(r.Context(), `INSERT INTO admin_oauth_states(state_hash,provider,code_verifier,expires_at) VALUES(?,?,?,?)`, oauthHash(state), provider.Code, verifier, time.Now().Add(adminOAuthStateTTL)) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建第三方登录请求失败") + return + } + a.cleanupAdminOAuthRecords(r.Context()) + + authorizationURL, _ := url.Parse(provider.AuthorizationURL) + query := authorizationURL.Query() + if provider.Code == "wechat" { + query.Set("appid", provider.ClientID) + } else { + query.Set("client_id", provider.ClientID) + } + query.Set("redirect_uri", provider.RedirectURI) + query.Set("response_type", "code") + query.Set("scope", provider.Scope) + query.Set("state", state) + if verifier != "" { + query.Set("code_challenge", pkceChallenge(verifier)) + query.Set("code_challenge_method", "S256") + } + authorizationURL.RawQuery = query.Encode() + if provider.Code == "wechat" { + authorizationURL.Fragment = "wechat_redirect" + } + reply(w, map[string]string{"authorizationUrl": authorizationURL.String(), "provider": provider.Code}) +} + +func (a *App) adminOAuthCallback(w http.ResponseWriter, r *http.Request) { + frontendURL, frontendErr := a.adminOAuthFrontendURL(r.Context()) + if frontendErr != nil { + fail(w, http.StatusServiceUnavailable, 50001, "第三方登录回调未配置") + return + } + state := strings.TrimSpace(r.URL.Query().Get("state")) + if state == "" { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "登录状态无效或已过期") + return + } + var providerCode, verifier string + err := a.db.QueryRowContext(r.Context(), `SELECT provider,code_verifier FROM admin_oauth_states WHERE state_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(state)).Scan(&providerCode, &verifier) + if err != nil { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "登录状态无效或已过期") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE admin_oauth_states SET used_at=NOW(3) WHERE state_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(state)) + if err != nil { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "第三方登录处理失败") + return + } + affected, _ := result.RowsAffected() + if affected != 1 { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "登录状态已被使用") + return + } + if providerError := strings.TrimSpace(r.URL.Query().Get("error")); providerError != "" { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "第三方授权已取消或失败") + return + } + code := strings.TrimSpace(r.URL.Query().Get("code")) + if code == "" { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "第三方平台未返回授权码") + return + } + if !a.configBool(r.Context(), "oauth."+providerCode+".enabled", false) { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "该登录方式已停用") + return + } + provider, err := a.adminOAuthProvider(r.Context(), providerCode) + if err != nil { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "该登录方式配置不可用") + return + } + identity, err := a.fetchAdminOAuthIdentity(r.Context(), provider, code, verifier) + if err != nil { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "获取第三方账号信息失败") + return + } + var adminUserID sql.NullInt64 + _ = a.db.QueryRowContext(r.Context(), `SELECT admin_user_id FROM admin_oauth_identities WHERE provider=? AND subject=?`, provider.Code, identity.Subject).Scan(&adminUserID) + loginCode := randomToken() + _, err = a.db.ExecContext(r.Context(), `INSERT INTO admin_oauth_login_codes(code_hash,provider,subject,email,display_name,avatar_url,admin_user_id,expires_at) VALUES(?,?,?,?,?,?,?,?)`, oauthHash(loginCode), provider.Code, identity.Subject, identity.Email, identity.DisplayName, identity.AvatarURL, adminUserID, time.Now().Add(adminOAuthCodeTTL)) + if err != nil { + a.redirectAdminOAuthResult(w, r, frontendURL, "", "创建登录凭证失败") + return + } + a.redirectAdminOAuthResult(w, r, frontendURL, loginCode, "") +} + +func (a *App) redirectAdminOAuthResult(w http.ResponseWriter, r *http.Request, frontendURL, code, message string) { + target, err := url.Parse(frontendURL) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "登录结果页地址无效") + return + } + query := target.Query() + if code != "" { + query.Set("oauthCode", code) + } else { + query.Set("oauthError", message) + } + target.RawQuery = query.Encode() + http.Redirect(w, r, target.String(), http.StatusFound) +} + +func (a *App) adminOAuthExchange(w http.ResponseWriter, r *http.Request) { + var req struct { + Code string `json:"code"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效") + return + } + if !a.rateLimit(w, r, "admin_oauth_exchange", clientIP(r), 20, 10*time.Minute) { + return + } + loginCode, err := a.readAdminOAuthLoginCode(r.Context(), strings.TrimSpace(req.Code), false) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效或已过期") + return + } + if !loginCode.AdminUserID.Valid { + reply(w, map[string]any{ + "requiresLink": true, + "provider": loginCode.Provider, + "providerName": adminOAuthProviderNames[loginCode.Provider], + "displayName": loginCode.DisplayName, + "email": loginCode.Email, + "avatarUrl": loginCode.AvatarURL, + }) + return + } + adminID, realName, err := a.consumeAdminOAuthCode(r.Context(), strings.TrimSpace(req.Code), loginCode.AdminUserID.Int64) + if err != nil { + fail(w, http.StatusUnauthorized, 10001, err.Error()) + return + } + payload, err := a.newAdminSession(r.Context(), w, r, adminID, realName) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建管理会话失败") + return + } + payload["requiresLink"] = false + reply(w, payload) +} + +func (a *App) adminOAuthLink(w http.ResponseWriter, r *http.Request) { + var req struct { + Code string `json:"code"` + Username string `json:"username"` + Password string `json:"password"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" || strings.TrimSpace(req.Username) == "" || req.Password == "" { + fail(w, http.StatusBadRequest, 20001, "请输入管理员账号和密码完成绑定") + return + } + username := strings.TrimSpace(req.Username) + if !a.rateLimit(w, r, "admin_oauth_link_ip", clientIP(r), 10, 15*time.Minute) || !a.rateLimit(w, r, "admin_oauth_link_user", strings.ToLower(username), 10, 15*time.Minute) { + return + } + loginCode, err := a.readAdminOAuthLoginCode(r.Context(), strings.TrimSpace(req.Code), true) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效或已过期") + return + } + var adminID int64 + var passwordHash, realName string + var status int + err = a.db.QueryRowContext(r.Context(), `SELECT id,password_hash,real_name,status FROM admin_users WHERE username=?`, username).Scan(&adminID, &passwordHash, &realName, &status) + if err != nil || !checkPassword(passwordHash, req.Password) { + // 这里返回 400,避免前端全局 401 拦截器丢弃尚可重试的一次性绑定码。 + fail(w, http.StatusBadRequest, 10001, "管理员账号或密码错误") + return + } + if status != 1 { + fail(w, http.StatusForbidden, 10006, "管理员账号已停用") + return + } + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(r.Context(), `UPDATE admin_oauth_login_codes SET used_at=NOW(3),admin_user_id=? WHERE code_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, adminID, oauthHash(strings.TrimSpace(req.Code))) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + affected, _ := result.RowsAffected() + if affected != 1 { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证已被使用") + return + } + _, err = tx.ExecContext(r.Context(), `INSERT INTO admin_oauth_identities(provider,subject,admin_user_id,email,display_name,avatar_url,last_login_at) VALUES(?,?,?,?,?,?,NOW(3))`, loginCode.Provider, loginCode.Subject, adminID, loginCode.Email, loginCode.DisplayName, loginCode.AvatarURL) + if err != nil { + fail(w, http.StatusConflict, 20001, "该第三方账号或管理员账号已绑定此渠道") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_users SET last_login_at=NOW(3) WHERE id=?`, adminID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + if err = tx.Commit(); err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + auditPayload, _ := json.Marshal(map[string]any{"provider": loginCode.Provider, "externalSubjectHash": fmt.Sprintf("%x", sha256.Sum256([]byte(loginCode.Subject)))}) + _, _ = a.db.ExecContext(r.Context(), `INSERT INTO admin_audit_logs(admin_user_id,action,target_type,target_id,request_data,ip) VALUES(?,?,?,?,?,?)`, adminID, "bind_oauth_identity", "admin_user", adminID, auditPayload, clientIP(r)) + payload, err := a.newAdminSession(r.Context(), w, r, adminID, realName) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建管理会话失败") + return + } + payload["requiresLink"] = false + reply(w, payload) +} + +func (a *App) readAdminOAuthLoginCode(ctx context.Context, code string, requireUnlinked bool) (adminOAuthLoginCode, error) { + var result adminOAuthLoginCode + query := `SELECT provider,subject,email,display_name,avatar_url,admin_user_id FROM admin_oauth_login_codes WHERE code_hash=? AND used_at IS NULL AND expires_at>NOW(3)` + if requireUnlinked { + query += ` AND admin_user_id IS NULL` + } + err := a.db.QueryRowContext(ctx, query, oauthHash(code)).Scan(&result.Provider, &result.Subject, &result.Email, &result.DisplayName, &result.AvatarURL, &result.AdminUserID) + return result, err +} + +func (a *App) consumeAdminOAuthCode(ctx context.Context, code string, adminID int64) (int64, string, error) { + var realName string + var status int + if err := a.db.QueryRowContext(ctx, `SELECT real_name,status FROM admin_users WHERE id=?`, adminID).Scan(&realName, &status); err != nil || status != 1 { + return 0, "", errors.New("管理员账号不存在或已停用") + } + result, err := a.db.ExecContext(ctx, `UPDATE admin_oauth_login_codes SET used_at=NOW(3) WHERE code_hash=? AND admin_user_id=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(code), adminID) + if err != nil { + return 0, "", errors.New("第三方登录处理失败") + } + affected, _ := result.RowsAffected() + if affected != 1 { + return 0, "", errors.New("第三方登录凭证无效或已使用") + } + _, _ = a.db.ExecContext(ctx, `UPDATE admin_users SET last_login_at=NOW(3) WHERE id=?`, adminID) + _, _ = a.db.ExecContext(ctx, `UPDATE admin_oauth_identities SET last_login_at=NOW(3) WHERE admin_user_id=?`, adminID) + return adminID, realName, nil +} + +func (a *App) cleanupAdminOAuthRecords(ctx context.Context) { + _, _ = a.db.ExecContext(ctx, `DELETE FROM admin_oauth_states WHERE expires_at= 300 { + return "", "", errors.New("第三方令牌服务请求失败") + } + var payload struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if json.Unmarshal(body, &payload) != nil || payload.AccessToken == "" { + parsed, parseErr := url.ParseQuery(string(body)) + if parseErr != nil { + return "", "", errors.New("第三方令牌响应无效") + } + payload.AccessToken = parsed.Get("access_token") + payload.Error = parsed.Get("error") + } + if payload.Error != "" || payload.AccessToken == "" { + return "", "", errors.New("第三方平台拒绝了令牌请求") + } + return payload.AccessToken, "", nil +} + +func (a *App) oauthBearerJSON(ctx context.Context, endpoint, accessToken string, out any) error { + request, _ := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + request.Header.Set("Authorization", "Bearer "+accessToken) + request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", "XingYu-Admin-OAuth/1.0") + return a.oauthDoJSON(request, out) +} + +func (a *App) oauthGetJSON(ctx context.Context, endpoint string, values url.Values, out any) error { + parsed, err := url.Parse(endpoint) + if err != nil { + return err + } + query := parsed.Query() + for key, items := range values { + for _, item := range items { + query.Add(key, item) + } + } + parsed.RawQuery = query.Encode() + request, _ := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", "XingYu-Admin-OAuth/1.0") + return a.oauthDoJSON(request, out) +} + +func (a *App) oauthDoJSON(request *http.Request, out any) error { + response, err := a.oauthHTTPClient().Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("第三方平台返回 HTTP %d", response.StatusCode) + } + decoder := json.NewDecoder(io.LimitReader(response.Body, adminOAuthBodyMax)) + return decoder.Decode(out) +} + +func (a *App) fetchQQOpenID(ctx context.Context, endpoint, accessToken string) (string, error) { + parsed, _ := url.Parse(endpoint) + query := parsed.Query() + query.Set("access_token", accessToken) + query.Set("fmt", "json") + parsed.RawQuery = query.Encode() + request, _ := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + request.Header.Set("Accept", "application/json") + response, err := a.oauthHTTPClient().Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, adminOAuthBodyMax)) + if err != nil || response.StatusCode < 200 || response.StatusCode >= 300 { + return "", errors.New("QQ OpenID 请求失败") + } + text := strings.TrimSpace(string(body)) + if strings.HasPrefix(text, "callback") { + start, end := strings.Index(text, "("), strings.LastIndex(text, ")") + if start >= 0 && end > start { + text = text[start+1 : end] + } + } + var payload struct { + OpenID string `json:"openid"` + Error int `json:"error"` + } + if json.Unmarshal([]byte(text), &payload) != nil || payload.Error != 0 || payload.OpenID == "" { + return "", errors.New("QQ OpenID 响应无效") + } + return payload.OpenID, nil +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_oauth_test.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_oauth_test.go new file mode 100644 index 0000000..2b43844 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_oauth_test.go @@ -0,0 +1,74 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestAdminOAuthEndpointAllowlist(t *testing.T) { + valid := map[string]string{ + "wechat": "https://api.weixin.qq.com/sns/userinfo", + "qq": "https://graph.qq.com/user/get_user_info", + "github": "https://api.github.com/user", + "google": "https://openidconnect.googleapis.com/v1/userinfo", + } + for provider, endpoint := range valid { + if err := validateAdminOAuthEndpoint(provider, endpoint); err != nil { + t.Fatalf("expected %s endpoint to be accepted: %v", provider, err) + } + } + invalid := []struct { + provider string + endpoint string + }{ + {"github", "http://api.github.com/user"}, + {"github", "https://127.0.0.1/user"}, + {"google", "https://evil.example.com/token"}, + {"github", "https://api.github.com:8443/user"}, + {"qq", "javascript:alert(1)"}, + } + for _, item := range invalid { + if err := validateAdminOAuthEndpoint(item.provider, item.endpoint); err == nil { + t.Fatalf("expected endpoint to be rejected: %s", item.endpoint) + } + } +} + +func TestAdminOAuthRedirectURLPolicy(t *testing.T) { + production := &App{config: productionConfigForTest()} + if err := production.validateAdminOAuthRedirectURL("https://admin.example.com/auth/social-callback"); err != nil { + t.Fatalf("expected HTTPS callback to be accepted: %v", err) + } + if err := production.validateAdminOAuthRedirectURL("http://localhost:5560/auth/social-callback"); err == nil { + t.Fatal("expected production HTTP callback to be rejected") + } + development := &App{config: Config{Environment: "development"}} + if err := development.validateAdminOAuthRedirectURL("http://127.0.0.1:8888/admin/v1/auth/oauth/callback"); err != nil { + t.Fatalf("expected local development callback to be accepted: %v", err) + } + if err := development.validateAdminOAuthRedirectURL("http://admin.example.com/callback"); err == nil { + t.Fatal("expected non-local HTTP callback to be rejected") + } +} + +func TestUserOAuthResultKeepsHashRouteAndAddsQuery(t *testing.T) { + app := &App{} + request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/callback", nil) + recorder := httptest.NewRecorder() + app.redirectUserOAuthResult(recorder, request, "http://localhost:5174/#/pages/auth/oauth-callback", "one-time-code", "") + if recorder.Code != http.StatusFound { + t.Fatalf("expected redirect status, got %d", recorder.Code) + } + target, err := url.Parse(recorder.Header().Get("Location")) + if err != nil { + t.Fatalf("invalid redirect URL: %v", err) + } + if target.Query().Get("oauthCode") != "one-time-code" { + t.Fatalf("missing one-time code in redirect: %s", target.String()) + } + if target.Fragment != "/pages/auth/oauth-callback" { + t.Fatalf("hash route was lost: %s", target.String()) + } +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_operations.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_operations.go new file mode 100644 index 0000000..bbc1e10 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_operations.go @@ -0,0 +1,572 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +func (a *App) loadAdminUserDetail(w http.ResponseWriter, r *http.Request, id int64) { + var publicID, nickname, avatar, cover, cityCode, city, occupation, bio string + var phoneCipher []byte + var birthday sql.NullString + var created time.Time + var lastActive sql.NullTime + var status, risk, gender, height, profileScore, vip, vipLevel int + var followingCount, followerCount, postCount, likeCount int + var isTest bool + var testBatch string + err := a.db.QueryRowContext(r.Context(), `SELECT u.public_id,u.phone_cipher,u.status,u.risk_level,u.created_at,u.is_test,u.test_batch, + p.nickname,p.avatar_url,p.cover_url,p.gender,DATE_FORMAT(p.birthday,'%Y-%m-%d'),COALESCE(p.height_cm,0),p.city_code,p.city_name,p.occupation,p.bio,p.profile_score,p.is_vip,p.vip_level,p.last_active_at, + (SELECT COUNT(*) FROM user_follows WHERE user_id=u.id),(SELECT COUNT(*) FROM user_follows WHERE target_user_id=u.id),(SELECT COUNT(*) FROM posts WHERE user_id=u.id AND deleted_at IS NULL),(SELECT COUNT(*) FROM post_likes pl JOIN posts po ON po.id=pl.post_id WHERE po.user_id=u.id) + FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.id=? AND u.deleted_at IS NULL`, id).Scan( + &publicID, &phoneCipher, &status, &risk, &created, &isTest, &testBatch, &nickname, &avatar, &cover, &gender, &birthday, &height, &cityCode, &city, &occupation, &bio, &profileScore, &vip, &vipLevel, &lastActive, &followingCount, &followerCount, &postCount, &likeCount) + if err != nil { + fail(w, http.StatusNotFound, 30001, "用户不存在") + return + } + phone, decryptErr := a.decryptPhone(phoneCipher) + if decryptErr != nil { + fail(w, http.StatusInternalServerError, 50001, "用户手机号解密失败") + return + } + + verification := map[string]any{"status": "UNVERIFIED", "type": "real_name", "realName": "", "documentMask": "", "remark": "", "evidence": []string{}} + verifications := []map[string]any{} + verificationRows, _ := a.db.QueryContext(r.Context(), `SELECT verification_type,status,real_name,document_mask,remark,COALESCE(evidence_json,'[]'),submitted_at,reviewed_at FROM user_verifications WHERE user_id=? ORDER BY verification_type='real_name' DESC,verification_type`, id) + if verificationRows != nil { + defer verificationRows.Close() + for verificationRows.Next() { + var verificationType, verificationStatus, realName, documentMask, verificationRemark, evidenceJSON string + var submittedAt, reviewedAt sql.NullTime + if verificationRows.Scan(&verificationType, &verificationStatus, &realName, &documentMask, &verificationRemark, &evidenceJSON, &submittedAt, &reviewedAt) != nil { + continue + } + evidence := []string{} + _ = json.Unmarshal([]byte(evidenceJSON), &evidence) + item := map[string]any{"type": verificationType, "status": verificationStatus, "realName": realName, "documentMask": documentMask, "remark": verificationRemark, "evidence": evidence, "submittedAt": nullableTime(submittedAt), "reviewedAt": nullableTime(reviewedAt)} + verifications = append(verifications, item) + if len(verifications) == 1 { + verification = item + } + } + } + + membership := map[string]any{"active": vip == 1, "level": vipLevel, "name": "普通用户"} + if vip == 1 { + membership["name"] = "历史会员资料" + } + var subscriptionID, planID int64 + var planName string + var level int + var startedAt, expiresAt time.Time + if a.db.QueryRowContext(r.Context(), `SELECT s.id,s.plan_id,p.name,p.level,s.started_at,s.expires_at FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id WHERE s.user_id=? AND s.status=1 AND s.expires_at>NOW(3) ORDER BY p.level DESC,s.expires_at DESC LIMIT 1`, id).Scan(&subscriptionID, &planID, &planName, &level, &startedAt, &expiresAt) == nil { + membership = map[string]any{"active": true, "subscriptionId": subscriptionID, "planId": planID, "name": planName, "level": level, "startedAt": startedAt, "expiresAt": expiresAt} + } + + devices := []map[string]any{} + rows, _ := a.db.QueryContext(r.Context(), `SELECT device_id,platform,device_model,os_version,app_version,last_ip,last_active_at,status FROM user_devices WHERE user_id=? ORDER BY COALESCE(last_active_at,created_at) DESC LIMIT 20`, id) + if rows != nil { + defer rows.Close() + for rows.Next() { + var deviceID, platform, model, osVersion, appVersion, ip string + var active any + var deviceStatus int + _ = rows.Scan(&deviceID, &platform, &model, &osVersion, &appVersion, &ip, &active, &deviceStatus) + devices = append(devices, map[string]any{"deviceId": deviceID, "platform": platform, "model": model, "osVersion": osVersion, "appVersion": appVersion, "ip": ip, "lastActiveAt": active, "status": deviceStatus}) + } + } + var activeSessions, orderCount, paidCent int64 + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_sessions WHERE user_id=? AND revoked_at IS NULL AND expires_at>NOW(3)`, id).Scan(&activeSessions) + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*),COALESCE(SUM(IF(status='PAID',amount_cent,0)),0) FROM orders WHERE user_id=? AND deleted_at IS NULL`, id).Scan(&orderCount, &paidCent) + + reply(w, map[string]any{ + "id": id, "publicId": publicID, "isTest": isTest, "testBatch": testBatch, "phone": phone, "status": status, "riskLevel": risk, "createdAt": created, + "profile": map[string]any{"id": id, "publicId": publicID, "nickname": nickname, "avatar": avatar, "cover": cover, "gender": gender, "birthday": nullableString(birthday), "height": height, "cityCode": cityCode, "city": city, "occupation": occupation, "bio": bio, "profileScore": profileScore, "vip": vip == 1, "vipLevel": vipLevel, "lastActiveAt": nullableTime(lastActive), "followingCount": followingCount, "followerCount": followerCount, "postCount": postCount, "likeCount": likeCount}, + "verification": verification, "verifications": verifications, "membership": membership, "sanctions": a.sanctionList(r.Context(), id), "devices": devices, + "security": map[string]any{"activeSessions": activeSessions}, "orderSummary": map[string]any{"count": orderCount, "paidCent": paidCent}, + }) +} + +func nullableTime(value sql.NullTime) any { + if value.Valid { + return value.Time + } + return nil +} + +func (a *App) adminUpdateUserProfile(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "用户编号无效") + return + } + var req struct { + Phone string `json:"phone"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Cover string `json:"cover"` + Gender int `json:"gender"` + Birthday string `json:"birthday"` + Height int `json:"height"` + CityCode string `json:"cityCode"` + City string `json:"city"` + Occupation string `json:"occupation"` + Bio string `json:"bio"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.Nickname) == "" || len([]rune(req.Nickname)) > 50 || req.Gender < 0 || req.Gender > 2 || req.Height < 0 || req.Height > 260 { + fail(w, 400, 20001, "用户资料格式不正确") + return + } + if req.Birthday != "" { + if _, err = time.Parse("2006-01-02", req.Birthday); err != nil { + fail(w, 400, 20001, "生日格式应为 YYYY-MM-DD") + return + } + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + defer func() { _ = tx.Rollback() }() + if strings.TrimSpace(req.Phone) != "" { + if !validPhone(req.Phone) { + fail(w, 400, 20001, "手机号格式不正确") + return + } + phoneCipher, encryptErr := a.encryptPhone(req.Phone) + if encryptErr != nil { + fail(w, 500, 50001, "加密手机号失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE users SET phone_hash=?,phone_cipher=? WHERE id=?`, phoneHash(req.Phone), phoneCipher, id); err != nil { + fail(w, http.StatusConflict, 20001, "手机号已被其他账号使用") + return + } + } + _, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET nickname=?,avatar_url=?,cover_url=?,gender=?,birthday=NULLIF(?,''),height_cm=NULLIF(?,0),city_code=?,city_name=?,occupation=?,bio=?,profile_score=GREATEST(profile_score,80) WHERE user_id=?`, strings.TrimSpace(req.Nickname), strings.TrimSpace(req.Avatar), strings.TrimSpace(req.Cover), req.Gender, req.Birthday, req.Height, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.City), strings.TrimSpace(req.Occupation), strings.TrimSpace(req.Bio), id) + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "保存失败") + return + } + a.audit(r, "update_profile", "user", id, map[string]any{"nickname": req.Nickname, "phoneChanged": req.Phone != "", "city": req.City}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminUpdateVerification(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "用户编号无效") + return + } + var req struct { + Type string `json:"type"` + Status string `json:"status"` + RealName string `json:"realName"` + DocumentMask string `json:"documentMask"` + Remark string `json:"remark"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "认证资料格式错误") + return + } + req.Status = strings.ToUpper(strings.TrimSpace(req.Status)) + if req.Type == "" { + req.Type = "real_name" + } + if req.Status != "UNVERIFIED" && req.Status != "PENDING" && req.Status != "VERIFIED" && req.Status != "REJECTED" { + fail(w, 400, 20001, "认证状态无效") + return + } + if req.Status == "REJECTED" && strings.TrimSpace(req.Remark) == "" { + fail(w, 400, 20001, "驳回认证时必须填写原因") + return + } + _, err = a.db.ExecContext(r.Context(), `INSERT INTO user_verifications(user_id,verification_type,status,real_name,document_mask,evidence_json,remark,reviewer_admin_id,submitted_at,reviewed_at) VALUES(?,?,?,?,?,'[]',?,?,IF(?='PENDING',NOW(3),NULL),IF(? IN ('VERIFIED','REJECTED'),NOW(3),NULL)) ON DUPLICATE KEY UPDATE verification_type=VALUES(verification_type),status=VALUES(status),real_name=VALUES(real_name),document_mask=VALUES(document_mask),remark=VALUES(remark),reviewer_admin_id=VALUES(reviewer_admin_id),submitted_at=IF(VALUES(status)='PENDING',COALESCE(submitted_at,NOW(3)),submitted_at),reviewed_at=IF(VALUES(status) IN ('VERIFIED','REJECTED'),NOW(3),NULL)`, id, req.Type, req.Status, strings.TrimSpace(req.RealName), strings.TrimSpace(req.DocumentMask), strings.TrimSpace(req.Remark), current(r).ID, req.Status, req.Status) + if err != nil { + fail(w, 500, 50001, "保存认证结果失败") + return + } + if req.Status == "VERIFIED" || req.Status == "REJECTED" { + title := "认证审核结果" + content := "认证已通过" + if req.Status == "REJECTED" { + content = "认证未通过:" + strings.TrimSpace(req.Remark) + } + a.notifyUser(r.Context(), id, "system", title, content, "verification", id) + } + a.audit(r, "verify", "user", id, map[string]any{"status": req.Status, "type": req.Type, "remark": req.Remark}) + reply(w, map[string]bool{"success": true}) +} + +func parseAdminExpiry(value string, fallbackDays int) (time.Time, error) { + if strings.TrimSpace(value) == "" { + return time.Now().AddDate(0, 0, fallbackDays), nil + } + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return parsed, nil + } + parsed, err := time.ParseInLocation("2006-01-02", value, time.Local) + if err != nil { + return time.Time{}, err + } + return parsed.Add(23*time.Hour + 59*time.Minute + 59*time.Second), nil +} + +func (a *App) adminUpdateMembership(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "用户编号无效") + return + } + var req struct { + Operation string `json:"operation"` + PlanID int64 `json:"planId"` + ExpiresAt string `json:"expiresAt"` + Reason string `json:"reason"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "会员设置格式错误") + return + } + if req.Operation == "" { + req.Operation = "grant" + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "会员设置失败") + return + } + defer func() { _ = tx.Rollback() }() + if req.Operation == "revoke" { + _, err = tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1`, id) + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET is_vip=0,vip_level=0 WHERE user_id=?`, id) + } + } else { + var durationDays, level int + if err = tx.QueryRowContext(r.Context(), `SELECT duration_days,level FROM membership_plans WHERE id=? AND deleted_at IS NULL`, req.PlanID).Scan(&durationDays, &level); err != nil { + fail(w, 400, 20001, "会员套餐不存在") + return + } + expiresAt, parseErr := parseAdminExpiry(req.ExpiresAt, durationDays) + if parseErr != nil || !expiresAt.After(time.Now()) { + fail(w, 400, 20001, "会员到期时间无效") + return + } + _, err = tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1`, id) + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(?,?,'admin',1,NOW(3),?)`, id, req.PlanID, expiresAt) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET is_vip=1,vip_level=? WHERE user_id=?`, level, id) + } + } + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "会员设置失败") + return + } + a.audit(r, "membership_"+req.Operation, "user", id, map[string]any{"planId": req.PlanID, "expiresAt": req.ExpiresAt, "reason": req.Reason}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) forceLogoutUser(ctx context.Context, userID, adminID int64, passwordReset bool) error { + if _, err := a.db.ExecContext(ctx, `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, userID); err != nil { + return err + } + if passwordReset { + _, err := a.db.ExecContext(ctx, `INSERT INTO user_security_controls(user_id,token_version,force_logout_at,password_reset_at,last_operator_admin_id) VALUES(?,1,NOW(3),NOW(3),?) ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=VALUES(force_logout_at),password_reset_at=VALUES(password_reset_at),last_operator_admin_id=VALUES(last_operator_admin_id)`, userID, adminID) + if err == nil { + a.hub.disconnect(userID) + } + return err + } + _, err := a.db.ExecContext(ctx, `INSERT INTO user_security_controls(user_id,token_version,force_logout_at,last_operator_admin_id) VALUES(?,1,NOW(3),?) ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=VALUES(force_logout_at),last_operator_admin_id=VALUES(last_operator_admin_id)`, userID, adminID) + if err == nil { + a.hub.disconnect(userID) + } + return err +} + +func (a *App) adminResetUserPassword(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + var req struct { + NewPassword string `json:"newPassword"` + } + if err != nil || decode(r, &req) != nil || !validUserPassword(req.NewPassword) { + fail(w, 400, 20001, "请填写有效的用户编号和新密码") + return + } + hash, err := hashPassword(req.NewPassword) + if err != nil { + fail(w, 500, 50001, "密码加密失败") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE users SET password_hash=? WHERE id=? AND deleted_at IS NULL`, hash, id) + if err != nil { + fail(w, 500, 50001, "重置密码失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 || a.forceLogoutUser(r.Context(), id, current(r).ID, true) != nil { + fail(w, 500, 50001, "重置密码失败") + return + } + a.audit(r, "reset_password", "user", id, map[string]any{"forceLogout": true}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminForceLogout(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil || a.forceLogoutUser(r.Context(), id, current(r).ID, false) != nil { + fail(w, 500, 50001, "强制下线失败") + return + } + a.audit(r, "force_logout", "user", id, nil) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) sanctionList(ctx context.Context, userID int64) []map[string]any { + items := []map[string]any{} + rows, err := a.db.QueryContext(ctx, `SELECT s.id,s.sanction_type,s.reason,s.starts_at,s.expires_at,s.status,s.operator_admin_id,COALESCE(a.real_name,''),s.revoked_at,s.created_at FROM user_sanctions s LEFT JOIN admin_users a ON a.id=s.operator_admin_id WHERE s.user_id=? ORDER BY s.created_at DESC LIMIT 100`, userID) + if err != nil { + return items + } + defer rows.Close() + for rows.Next() { + var id, operatorID int64 + var typ, reason, status, operatorName string + var startsAt, createdAt time.Time + var expiresAt, revokedAt any + _ = rows.Scan(&id, &typ, &reason, &startsAt, &expiresAt, &status, &operatorID, &operatorName, &revokedAt, &createdAt) + items = append(items, map[string]any{"id": id, "type": typ, "reason": reason, "startsAt": startsAt, "expiresAt": expiresAt, "status": status, "operatorId": operatorID, "operatorName": operatorName, "revokedAt": revokedAt, "createdAt": createdAt}) + } + return items +} + +func (a *App) createSanction(ctx context.Context, adminID, userID int64, typ, reason string, expiresAt *time.Time) (int64, error) { + result, err := a.db.ExecContext(ctx, `INSERT INTO user_sanctions(user_id,sanction_type,reason,expires_at,operator_admin_id) VALUES(?,?,?,?,?)`, userID, typ, reason, expiresAt, adminID) + if err != nil { + return 0, err + } + id, _ := result.LastInsertId() + if typ == "FREEZE" || typ == "BAN" { + status := 2 + if typ == "BAN" { + status = 3 + } + if _, err = a.db.ExecContext(ctx, `UPDATE users SET status=? WHERE id=?`, status, userID); err == nil { + err = a.forceLogoutUser(ctx, userID, adminID, false) + } + } + if err == nil { + title := map[string]string{"WARNING": "违规警告", "MUTE": "禁言通知", "CONTENT_LIMIT": "内容发布限制", "FREEZE": "账号冻结", "BAN": "账号封禁"}[typ] + a.notifyUser(ctx, userID, "system", title, reason, "sanction", nil) + } + return id, err +} + +func (a *App) adminUserSanctions(w http.ResponseWriter, r *http.Request) { + userID, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "用户编号无效") + return + } + if r.Method == http.MethodGet { + reply(w, map[string]any{"items": a.sanctionList(r.Context(), userID)}) + return + } + var req struct { + Type string `json:"type"` + Reason string `json:"reason"` + ExpiresAt string `json:"expiresAt"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "处罚信息格式错误") + return + } + req.Type = strings.ToUpper(strings.TrimSpace(req.Type)) + allowed := map[string]bool{"WARNING": true, "FREEZE": true, "BAN": true, "MUTE": true, "CONTENT_LIMIT": true} + if !allowed[req.Type] || strings.TrimSpace(req.Reason) == "" { + fail(w, 400, 20001, "请选择处罚类型并填写原因") + return + } + var expiry *time.Time + if req.ExpiresAt != "" { + parsed, parseErr := parseAdminExpiry(req.ExpiresAt, 0) + if parseErr != nil || !parsed.After(time.Now()) { + fail(w, 400, 20001, "处罚到期时间无效") + return + } + expiry = &parsed + } + sanctionID, err := a.createSanction(r.Context(), current(r).ID, userID, req.Type, strings.TrimSpace(req.Reason), expiry) + if err != nil { + fail(w, 500, 50001, "执行处罚失败") + return + } + a.audit(r, "sanction", "user", userID, map[string]any{"sanctionId": sanctionID, "type": req.Type, "reason": req.Reason, "expiresAt": req.ExpiresAt}) + reply(w, map[string]any{"id": sanctionID}) +} + +func (a *App) adminRevokeSanction(w http.ResponseWriter, r *http.Request) { + sanctionID, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "处罚编号无效") + return + } + var userID int64 + var typ string + if err = a.db.QueryRowContext(r.Context(), `SELECT user_id,sanction_type FROM user_sanctions WHERE id=? AND status='ACTIVE'`, sanctionID).Scan(&userID, &typ); err != nil { + fail(w, 404, 30001, "有效处罚不存在") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE user_sanctions SET status='REVOKED',revoked_by=?,revoked_at=NOW(3) WHERE id=? AND status='ACTIVE'`, current(r).ID, sanctionID) + affected, _ := result.RowsAffected() + if err != nil || affected == 0 { + fail(w, 500, 50001, "撤销处罚失败") + return + } + if typ == "FREEZE" || typ == "BAN" { + var bans, freezes int + _ = a.db.QueryRowContext(r.Context(), `SELECT SUM(sanction_type='BAN'),SUM(sanction_type='FREEZE') FROM user_sanctions WHERE user_id=? AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>NOW(3))`, userID).Scan(&bans, &freezes) + status := 1 + if bans > 0 { + status = 3 + } else if freezes > 0 { + status = 2 + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE users SET status=? WHERE id=?`, status, userID) + } + a.audit(r, "revoke_sanction", "user", userID, map[string]any{"sanctionId": sanctionID, "type": typ}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) isSanctionActive(ctx context.Context, userID int64, typ string) bool { + var count int + _ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_sanctions WHERE user_id=? AND sanction_type=? AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>NOW(3))`, userID, typ).Scan(&count) + return count > 0 +} + +func (a *App) normalizeUserStatus(ctx context.Context, userID int64, status int) int { + if status != 2 && status != 3 { + return status + } + var total int + _ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_sanctions WHERE user_id=? AND sanction_type IN ('FREEZE','BAN')`, userID).Scan(&total) + if total == 0 { + return status + } + _, _ = a.db.ExecContext(ctx, `UPDATE user_sanctions SET status='EXPIRED' WHERE user_id=? AND sanction_type IN ('FREEZE','BAN') AND status='ACTIVE' AND expires_at<=NOW(3)`, userID) + var bans, freezes int + _ = a.db.QueryRowContext(ctx, `SELECT SUM(sanction_type='BAN'),SUM(sanction_type='FREEZE') FROM user_sanctions WHERE user_id=? AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>NOW(3))`, userID).Scan(&bans, &freezes) + resolved := 1 + if bans > 0 { + resolved = 3 + } else if freezes > 0 { + resolved = 2 + } + if resolved != status { + _, _ = a.db.ExecContext(ctx, `UPDATE users SET status=? WHERE id=?`, resolved, userID) + } + return resolved +} + +func (a *App) recomputeMembershipTx(ctx context.Context, tx *sql.Tx, userID int64) error { + var level sql.NullInt64 + if err := tx.QueryRowContext(ctx, `SELECT MAX(p.level) FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id WHERE s.user_id=? AND s.status=1 AND s.expires_at>NOW(3)`, userID).Scan(&level); err != nil { + return err + } + if !level.Valid { + _, err := tx.ExecContext(ctx, `UPDATE user_profiles SET is_vip=0,vip_level=0 WHERE user_id=?`, userID) + return err + } + _, err := tx.ExecContext(ctx, `UPDATE user_profiles SET is_vip=1,vip_level=? WHERE user_id=?`, level.Int64, userID) + return err +} + +func (a *App) adminOrderTransition(w http.ResponseWriter, r *http.Request, action string) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "订单编号无效") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "订单操作失败") + return + } + defer func() { _ = tx.Rollback() }() + var userID, planID int64 + var status string + if err = tx.QueryRowContext(r.Context(), `SELECT user_id,product_id,status FROM orders WHERE id=? AND deleted_at IS NULL FOR UPDATE`, id).Scan(&userID, &planID, &status); err != nil { + fail(w, 404, 30001, "订单不存在") + return + } + switch action { + case "pay": + if status != "CREATED" { + fail(w, 400, 20001, "只有待支付订单可标记为已支付") + return + } + var durationDays, level int + if err = tx.QueryRowContext(r.Context(), `SELECT duration_days,level FROM membership_plans WHERE id=? AND deleted_at IS NULL`, planID).Scan(&durationDays, &level); err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='PAID',paid_at=NOW(3) WHERE id=?`, id) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(?,?,?,1,NOW(3),DATE_ADD(NOW(3),INTERVAL ? DAY))`, userID, planID, fmt.Sprintf("admin_order:%d", id), durationDays) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET is_vip=1,vip_level=GREATEST(vip_level,?) WHERE user_id=?`, level, userID) + } + case "close": + if status != "CREATED" { + fail(w, 400, 20001, "只有待支付订单可关闭") + return + } + _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='CLOSED' WHERE id=?`, id) + case "refund": + if status != "PAID" && status != "REFUND_REQUESTED" { + fail(w, 400, 20001, "只有已支付或用户已申请退款的订单可退款") + return + } + _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='REFUNDED' WHERE id=?`, id) + if err == nil { + result, updateErr := tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE user_id=? AND plan_id=? AND status=1 AND source IN (?,?)`, userID, planID, fmt.Sprintf("order:%d", id), fmt.Sprintf("admin_order:%d", id)) + err = updateErr + affected, _ := result.RowsAffected() + if err == nil && affected == 0 { + _, err = tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE id=(SELECT id FROM (SELECT id FROM subscriptions WHERE user_id=? AND plan_id=? AND status=1 ORDER BY started_at DESC LIMIT 1) latest)`, userID, planID) + } + } + if err == nil { + err = a.recomputeMembershipTx(r.Context(), tx, userID) + } + default: + fail(w, 400, 20001, "订单操作无效") + return + } + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "订单操作失败") + return + } + a.audit(r, action, "order", id, map[string]any{"userId": userID, "previousStatus": status}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) adminMarkOrderPaid(w http.ResponseWriter, r *http.Request) { + if a.configPlain(r.Context(), "payment.mode", "sandbox") == "live" { + fail(w, http.StatusBadRequest, 20001, "生产支付订单只能由已验签的支付回调确认入账") + return + } + a.adminOrderTransition(w, r, "pay") +} + +func (a *App) adminCloseOrder(w http.ResponseWriter, r *http.Request) { + a.adminOrderTransition(w, r, "close") +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_rbac.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_rbac.go new file mode 100644 index 0000000..7459cbe --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_rbac.go @@ -0,0 +1,525 @@ +package app + +import ( + "context" + "database/sql" + "net/http" + "regexp" + "sort" + "strings" + "time" +) + +type adminRoleView struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + BuiltIn bool `json:"builtIn"` + Status int `json:"status"` + Permissions []string `json:"permissions"` +} + +var adminAccountPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.-]{2,49}$`) +var adminRoleCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{2,49}$`) + +func (a *App) adminAccess(ctx context.Context, adminID int64) ([]string, []string, error) { + roleRows, err := a.db.QueryContext(ctx, `SELECT DISTINCT r.role_code FROM admin_user_roles ur JOIN admin_roles r ON r.id=ur.role_id WHERE ur.admin_user_id=? AND r.status=1 ORDER BY r.role_code`, adminID) + if err != nil { + return nil, nil, err + } + roles := []string{} + for roleRows.Next() { + var code string + if err = roleRows.Scan(&code); err != nil { + _ = roleRows.Close() + return nil, nil, err + } + roles = append(roles, code) + } + if err = roleRows.Close(); err != nil { + return nil, nil, err + } + + permissionRows, err := a.db.QueryContext(ctx, `SELECT DISTINCT rp.permission_code + FROM admin_user_roles ur + JOIN admin_roles r ON r.id=ur.role_id AND r.status=1 + JOIN admin_role_permissions rp ON rp.role_id=r.id + WHERE ur.admin_user_id=? ORDER BY rp.permission_code`, adminID) + if err != nil { + return nil, nil, err + } + permissions := []string{} + for permissionRows.Next() { + var code string + if err = permissionRows.Scan(&code); err != nil { + _ = permissionRows.Close() + return nil, nil, err + } + permissions = append(permissions, code) + } + err = permissionRows.Close() + return roles, permissions, err +} + +func (a *App) hasAdminPermission(ctx context.Context, adminID int64, permission string) bool { + var allowed int + err := a.db.QueryRowContext(ctx, `SELECT EXISTS( + SELECT 1 FROM admin_user_roles ur + JOIN admin_roles r ON r.id=ur.role_id AND r.status=1 + JOIN admin_role_permissions rp ON rp.role_id=r.id + WHERE ur.admin_user_id=? AND rp.permission_code=?)`, adminID, permission).Scan(&allowed) + return err == nil && allowed == 1 +} + +func (a *App) requireAdminPermission(permission string, next http.HandlerFunc) http.HandlerFunc { + return a.requireAuth("admin", func(w http.ResponseWriter, r *http.Request) { + if !a.hasAdminPermission(r.Context(), current(r).ID, permission) { + fail(w, http.StatusForbidden, 10006, "没有执行该操作的权限") + return + } + next(w, r) + }) +} + +func (a *App) adminCodes(w http.ResponseWriter, r *http.Request) { + _, permissions, err := a.adminAccess(r.Context(), current(r).ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取权限失败") + return + } + reply(w, permissions) +} + +func (a *App) adminRoleIDs(ctx context.Context, adminID int64) []int64 { + rows, err := a.db.QueryContext(ctx, `SELECT role_id FROM admin_user_roles WHERE admin_user_id=? ORDER BY role_id`, adminID) + if err != nil { + return []int64{} + } + defer rows.Close() + result := []int64{} + for rows.Next() { + var id int64 + if rows.Scan(&id) == nil { + result = append(result, id) + } + } + return result +} + +func (a *App) adminAccounts(w http.ResponseWriter, r *http.Request) { + page, size, offset := pagination(r) + keyword := strings.TrimSpace(r.URL.Query().Get("keyword")) + where := "" + args := []any{} + if keyword != "" { + where = ` WHERE username LIKE ? OR real_name LIKE ?` + like := "%" + keyword + "%" + args = append(args, like, like) + } + var total int64 + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM admin_users`+where, args...).Scan(&total); err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询管理员失败") + return + } + queryArgs := append(append([]any{}, args...), size, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT id,username,real_name,avatar_url,status,last_login_at,password_changed_at,created_at FROM admin_users`+where+` ORDER BY id LIMIT ? OFFSET ?`, queryArgs...) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询管理员失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var username, realName, avatar string + var status int + var lastLogin, passwordChanged sql.NullTime + var createdAt time.Time + if err = rows.Scan(&id, &username, &realName, &avatar, &status, &lastLogin, &passwordChanged, &createdAt); err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取管理员失败") + return + } + roles, _, _ := a.adminAccess(r.Context(), id) + items = append(items, map[string]any{ + "id": id, "username": username, "realName": realName, "avatar": avatar, + "status": status, "roles": roles, "roleIds": a.adminRoleIDs(r.Context(), id), + "lastLoginAt": nullableTime(lastLogin), "passwordChangedAt": nullableTime(passwordChanged), "createdAt": createdAt, + }) + } + reply(w, pageResult{Items: items, Total: total, Page: page, Size: size}) +} + +func validateAdminRoleIDs(ctx context.Context, tx *sql.Tx, roleIDs []int64) error { + if len(roleIDs) == 0 { + return sql.ErrNoRows + } + seen := map[int64]bool{} + for _, roleID := range roleIDs { + if roleID <= 0 || seen[roleID] { + return sql.ErrNoRows + } + seen[roleID] = true + var status int + if err := tx.QueryRowContext(ctx, `SELECT status FROM admin_roles WHERE id=?`, roleID).Scan(&status); err != nil || status != 1 { + return sql.ErrNoRows + } + } + return nil +} + +func replaceAdminRoles(ctx context.Context, tx *sql.Tx, adminID int64, roleIDs []int64) error { + if err := validateAdminRoleIDs(ctx, tx, roleIDs); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM admin_user_roles WHERE admin_user_id=?`, adminID); err != nil { + return err + } + for _, roleID := range roleIDs { + if _, err := tx.ExecContext(ctx, `INSERT INTO admin_user_roles(admin_user_id,role_id) VALUES(?,?)`, adminID, roleID); err != nil { + return err + } + } + return nil +} + +func (a *App) adminCreateAccount(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + RealName string `json:"realName"` + Password string `json:"password"` + Avatar string `json:"avatar"` + RoleIDs []int64 `json:"roleIds"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "管理员资料格式错误") + return + } + req.Username = strings.TrimSpace(req.Username) + req.RealName = strings.TrimSpace(req.RealName) + if !adminAccountPattern.MatchString(req.Username) || req.RealName == "" || len([]rune(req.RealName)) > 50 { + fail(w, http.StatusBadRequest, 20001, "账号或姓名格式无效") + return + } + if req.Password == "" { + fail(w, http.StatusBadRequest, 20001, "请输入密码") + return + } + hash, err := hashPassword(req.Password) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "密码加密失败") + return + } + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建管理员失败") + return + } + defer func() { _ = tx.Rollback() }() + if err = validateAdminRoleIDs(r.Context(), tx, req.RoleIDs); err != nil { + fail(w, http.StatusBadRequest, 20001, "请至少选择一个有效角色") + return + } + result, err := tx.ExecContext(r.Context(), `INSERT INTO admin_users(username,password_hash,real_name,avatar_url,status,password_changed_at) VALUES(?,?,?,?,1,NOW(3))`, req.Username, hash, req.RealName, strings.TrimSpace(req.Avatar)) + if err != nil { + fail(w, http.StatusConflict, 20001, "管理员账号已存在") + return + } + adminID, _ := result.LastInsertId() + if err = replaceAdminRoles(r.Context(), tx, adminID, req.RoleIDs); err != nil || tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "保存管理员角色失败") + return + } + a.audit(r, "create", "admin_user", adminID, map[string]any{"username": req.Username, "roleIds": req.RoleIDs}) + reply(w, map[string]any{"id": adminID, "success": true}) +} + +func containsInt64(values []int64, expected int64) bool { + for _, value := range values { + if value == expected { + return true + } + } + return false +} + +func (a *App) ensureSuperAdminRemains(ctx context.Context, tx *sql.Tx, targetID int64, newStatus int, roleIDs []int64) error { + var superRoleID int64 + if err := tx.QueryRowContext(ctx, `SELECT id FROM admin_roles WHERE role_code='super_admin'`).Scan(&superRoleID); err != nil { + return err + } + var targetWasSuper int + if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM admin_user_roles WHERE admin_user_id=? AND role_id=?)`, targetID, superRoleID).Scan(&targetWasSuper); err != nil { + return err + } + if targetWasSuper == 0 || (newStatus == 1 && containsInt64(roleIDs, superRoleID)) { + return nil + } + var remaining int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT a.id) FROM admin_users a JOIN admin_user_roles ur ON ur.admin_user_id=a.id WHERE a.status=1 AND a.id<>? AND ur.role_id=?`, targetID, superRoleID).Scan(&remaining); err != nil { + return err + } + if remaining == 0 { + return sql.ErrNoRows + } + return nil +} + +func (a *App) adminUpdateAccount(w http.ResponseWriter, r *http.Request) { + adminID, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "管理员 ID 无效") + return + } + var req struct { + RealName string `json:"realName"` + Avatar string `json:"avatar"` + Password string `json:"password"` + Status int `json:"status"` + RoleIDs []int64 `json:"roleIds"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.RealName) == "" || (req.Status != 0 && req.Status != 1) { + fail(w, http.StatusBadRequest, 20001, "管理员资料格式错误") + return + } + if current(r).ID == adminID && req.Status != 1 { + fail(w, http.StatusBadRequest, 20001, "不能停用当前登录账号") + return + } + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新管理员失败") + return + } + defer func() { _ = tx.Rollback() }() + var exists int64 + if err = tx.QueryRowContext(r.Context(), `SELECT id FROM admin_users WHERE id=? FOR UPDATE`, adminID).Scan(&exists); err != nil { + fail(w, http.StatusNotFound, 30001, "管理员不存在") + return + } + if err = validateAdminRoleIDs(r.Context(), tx, req.RoleIDs); err != nil { + fail(w, http.StatusBadRequest, 20001, "请至少选择一个有效角色") + return + } + if err = a.ensureSuperAdminRemains(r.Context(), tx, adminID, req.Status, req.RoleIDs); err != nil { + fail(w, http.StatusBadRequest, 20001, "必须保留至少一个启用的超级管理员") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_users SET real_name=?,avatar_url=?,status=? WHERE id=?`, strings.TrimSpace(req.RealName), strings.TrimSpace(req.Avatar), req.Status, adminID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新管理员失败") + return + } + if err = replaceAdminRoles(r.Context(), tx, adminID, req.RoleIDs); err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新管理员角色失败") + return + } + passwordChanged := false + if req.Password != "" { + hash, hashErr := hashPassword(req.Password) + if hashErr != nil { + fail(w, http.StatusInternalServerError, 50001, "密码加密失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_users SET password_hash=?,token_version=token_version+1,password_changed_at=NOW(3) WHERE id=?`, hash, adminID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新管理员密码失败") + return + } + passwordChanged = true + } + if req.Status != 1 || passwordChanged { + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_sessions SET revoked_at=NOW(3) WHERE admin_user_id=? AND revoked_at IS NULL`, adminID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "撤销管理员会话失败") + return + } + } + if err = tx.Commit(); err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新管理员失败") + return + } + a.audit(r, "update", "admin_user", adminID, map[string]any{"roleIds": req.RoleIDs, "status": req.Status, "passwordChanged": passwordChanged}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) rolePermissions(ctx context.Context, roleID int64) []string { + rows, err := a.db.QueryContext(ctx, `SELECT permission_code FROM admin_role_permissions WHERE role_id=? ORDER BY permission_code`, roleID) + if err != nil { + return []string{} + } + defer rows.Close() + permissions := []string{} + for rows.Next() { + var code string + if rows.Scan(&code) == nil { + permissions = append(permissions, code) + } + } + return permissions +} + +func (a *App) adminRoles(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,role_code,role_name,description,built_in,status FROM admin_roles ORDER BY built_in DESC,id`) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询角色失败") + return + } + defer rows.Close() + items := []adminRoleView{} + for rows.Next() { + var item adminRoleView + var builtIn int + if err = rows.Scan(&item.ID, &item.Code, &item.Name, &item.Description, &builtIn, &item.Status); err != nil { + fail(w, http.StatusInternalServerError, 50001, "读取角色失败") + return + } + item.BuiltIn = builtIn == 1 + item.Permissions = a.rolePermissions(r.Context(), item.ID) + items = append(items, item) + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) adminPermissions(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT permission_code,permission_name,permission_group FROM admin_permissions ORDER BY permission_group,sort_order,permission_code`) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询权限失败") + return + } + defer rows.Close() + items := []map[string]string{} + for rows.Next() { + var code, name, group string + if rows.Scan(&code, &name, &group) == nil { + items = append(items, map[string]string{"code": code, "name": name, "group": group}) + } + } + reply(w, map[string]any{"items": items}) +} + +func validatePermissionCodes(ctx context.Context, tx *sql.Tx, permissions []string) error { + if len(permissions) == 0 { + return sql.ErrNoRows + } + seen := map[string]bool{} + for _, permission := range permissions { + permission = strings.TrimSpace(permission) + if permission == "" || seen[permission] { + return sql.ErrNoRows + } + seen[permission] = true + var exists int + if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM admin_permissions WHERE permission_code=?)`, permission).Scan(&exists); err != nil || exists != 1 { + return sql.ErrNoRows + } + } + return nil +} + +func replaceRolePermissions(ctx context.Context, tx *sql.Tx, roleID int64, permissions []string) error { + if err := validatePermissionCodes(ctx, tx, permissions); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM admin_role_permissions WHERE role_id=?`, roleID); err != nil { + return err + } + sort.Strings(permissions) + for _, permission := range permissions { + if _, err := tx.ExecContext(ctx, `INSERT INTO admin_role_permissions(role_id,permission_code) VALUES(?,?)`, roleID, permission); err != nil { + return err + } + } + return nil +} + +func (a *App) adminCreateRole(w http.ResponseWriter, r *http.Request) { + a.adminSaveRole(w, r, 0) +} + +func (a *App) adminUpdateRole(w http.ResponseWriter, r *http.Request) { + roleID, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "角色 ID 无效") + return + } + a.adminSaveRole(w, r, roleID) +} + +func (a *App) adminSaveRole(w http.ResponseWriter, r *http.Request, roleID int64) { + var req struct { + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + Permissions []string `json:"permissions"` + Status int `json:"status"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "角色资料格式错误") + return + } + req.Code = strings.TrimSpace(req.Code) + req.Name = strings.TrimSpace(req.Name) + if !adminRoleCodePattern.MatchString(req.Code) || req.Name == "" || len([]rune(req.Name)) > 80 || (req.Status != 0 && req.Status != 1) { + fail(w, http.StatusBadRequest, 20001, "角色编码、名称或状态无效") + return + } + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存角色失败") + return + } + defer func() { _ = tx.Rollback() }() + if err = validatePermissionCodes(r.Context(), tx, req.Permissions); err != nil { + fail(w, http.StatusBadRequest, 20001, "请至少选择一个有效权限") + return + } + if roleID == 0 { + result, insertErr := tx.ExecContext(r.Context(), `INSERT INTO admin_roles(role_code,role_name,description,status) VALUES(?,?,?,?)`, req.Code, req.Name, strings.TrimSpace(req.Description), req.Status) + if insertErr != nil { + fail(w, http.StatusConflict, 20001, "角色编码已存在") + return + } + roleID, _ = result.LastInsertId() + } else { + var builtIn int + if err = tx.QueryRowContext(r.Context(), `SELECT built_in FROM admin_roles WHERE id=? FOR UPDATE`, roleID).Scan(&builtIn); err != nil { + fail(w, http.StatusNotFound, 30001, "角色不存在") + return + } + if builtIn == 1 { + fail(w, http.StatusBadRequest, 20001, "内置超级管理员角色不可修改") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE admin_roles SET role_code=?,role_name=?,description=?,status=? WHERE id=?`, req.Code, req.Name, strings.TrimSpace(req.Description), req.Status, roleID); err != nil { + fail(w, http.StatusConflict, 20001, "角色编码已存在") + return + } + } + if err = replaceRolePermissions(r.Context(), tx, roleID, req.Permissions); err != nil || tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "保存角色权限失败") + return + } + a.audit(r, "save", "admin_role", roleID, map[string]any{"code": req.Code, "permissions": req.Permissions, "status": req.Status}) + reply(w, map[string]any{"id": roleID, "success": true}) +} + +func (a *App) adminDeleteRole(w http.ResponseWriter, r *http.Request) { + roleID, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "角色 ID 无效") + return + } + var builtIn, assigned int + if err = a.db.QueryRowContext(r.Context(), `SELECT built_in,(SELECT COUNT(*) FROM admin_user_roles WHERE role_id=admin_roles.id) FROM admin_roles WHERE id=?`, roleID).Scan(&builtIn, &assigned); err != nil { + fail(w, http.StatusNotFound, 30001, "角色不存在") + return + } + if builtIn == 1 || assigned > 0 { + fail(w, http.StatusBadRequest, 20001, "内置角色或仍被管理员使用的角色不能删除") + return + } + if _, err = a.db.ExecContext(r.Context(), `DELETE FROM admin_roles WHERE id=?`, roleID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "删除角色失败") + return + } + a.audit(r, "delete", "admin_role", roleID, nil) + reply(w, map[string]bool{"success": true}) +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_sessions.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_sessions.go new file mode 100644 index 0000000..44b157f --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_sessions.go @@ -0,0 +1,183 @@ +package app + +import ( + "context" + "crypto/sha256" + "database/sql" + "errors" + "net/http" + "strings" + "time" +) + +const ( + adminAccessTokenTTL = 30 * time.Minute + adminRefreshTokenTTL = 7 * 24 * time.Hour + adminRefreshCookieKey = "xingyu_admin_refresh" +) + +func adminUserAgentHash(r *http.Request) []byte { + sum := sha256.Sum256([]byte(strings.TrimSpace(r.UserAgent()))) + return sum[:] +} + +func (a *App) setAdminRefreshCookie(w http.ResponseWriter, token string, expiresAt time.Time) { + maxAge := int(time.Until(expiresAt).Seconds()) + if maxAge < 0 { + maxAge = 0 + } + sameSite := http.SameSiteLaxMode + secure := a.config.Environment == "production" + if secure { + // Production supports a dedicated HTTPS API domain while keeping the + // refresh token inaccessible to JavaScript. + sameSite = http.SameSiteNoneMode + } + http.SetCookie(w, &http.Cookie{ + Name: adminRefreshCookieKey, + Value: token, + Path: "/admin/v1/auth", + Expires: expiresAt, + MaxAge: maxAge, + HttpOnly: true, + Secure: secure, + SameSite: sameSite, + }) +} + +func (a *App) clearAdminRefreshCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: adminRefreshCookieKey, + Value: "", + Path: "/admin/v1/auth", + Expires: time.Unix(1, 0), + MaxAge: -1, + HttpOnly: true, + Secure: a.config.Environment == "production", + SameSite: func() http.SameSite { + if a.config.Environment == "production" { + return http.SameSiteNoneMode + } + return http.SameSiteLaxMode + }(), + }) +} + +func (a *App) adminRefreshTokenFromRequest(r *http.Request) string { + if cookie, err := r.Cookie(adminRefreshCookieKey); err == nil { + if token := strings.TrimSpace(cookie.Value); token != "" { + return token + } + } + if r.Body == nil || r.ContentLength == 0 { + return "" + } + var req struct { + RefreshToken string `json:"refreshToken"` + } + if decode(r, &req) != nil { + return "" + } + return strings.TrimSpace(req.RefreshToken) +} + +func (a *App) newAdminSession(ctx context.Context, w http.ResponseWriter, r *http.Request, adminID int64, realName string) (map[string]any, error) { + refreshToken := randomToken() + refreshHash := sha256.Sum256([]byte(refreshToken)) + expiresAt := time.Now().Add(adminRefreshTokenTTL) + if _, err := a.db.ExecContext(ctx, `INSERT INTO admin_sessions(admin_user_id,refresh_token_hash,user_agent_hash,ip,expires_at) VALUES(?,?,?,?,?)`, adminID, refreshHash[:], adminUserAgentHash(r), clientIP(r), expiresAt); err != nil { + return nil, err + } + accessToken, err := a.token(adminID, "admin", realName, adminAccessTokenTTL) + if err != nil { + _, _ = a.db.ExecContext(ctx, `UPDATE admin_sessions SET revoked_at=NOW(3) WHERE refresh_token_hash=?`, refreshHash[:]) + return nil, err + } + a.setAdminRefreshCookie(w, refreshToken, expiresAt) + result := map[string]any{"accessToken": accessToken, "expiresIn": int(adminAccessTokenTTL.Seconds())} + if a.config.Environment != "production" { + // HTTP development across localhost/127.0.0.1 cannot use a SameSite=None + // cookie. The fallback is sessionStorage-only on the admin client. + result["refreshToken"] = refreshToken + } + return result, nil +} + +func (a *App) rotateAdminSession(ctx context.Context, w http.ResponseWriter, r *http.Request, refreshToken string) (map[string]any, error) { + oldHash := sha256.Sum256([]byte(refreshToken)) + tx, err := a.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + + var sessionID, adminID int64 + var realName string + var status int + var expiresAt time.Time + err = tx.QueryRowContext(ctx, `SELECT s.id,a.id,a.real_name,a.status,s.expires_at + FROM admin_sessions s JOIN admin_users a ON a.id=s.admin_user_id + WHERE s.refresh_token_hash=? AND s.revoked_at IS NULL AND s.expires_at>NOW(3) + FOR UPDATE`, oldHash[:]).Scan(&sessionID, &adminID, &realName, &status, &expiresAt) + if err != nil || status != 1 { + return nil, errors.New("刷新令牌无效或已过期") + } + + newRefreshToken := randomToken() + newHash := sha256.Sum256([]byte(newRefreshToken)) + result, err := tx.ExecContext(ctx, `UPDATE admin_sessions SET refresh_token_hash=?,user_agent_hash=?,ip=?,last_active_at=NOW(3) WHERE id=? AND refresh_token_hash=? AND revoked_at IS NULL`, newHash[:], adminUserAgentHash(r), clientIP(r), sessionID, oldHash[:]) + if err != nil { + return nil, err + } + affected, _ := result.RowsAffected() + if affected != 1 { + return nil, errors.New("刷新令牌已被使用") + } + if err = tx.Commit(); err != nil { + return nil, err + } + + accessToken, err := a.token(adminID, "admin", realName, adminAccessTokenTTL) + if err != nil { + return nil, err + } + a.setAdminRefreshCookie(w, newRefreshToken, expiresAt) + payload := map[string]any{"accessToken": accessToken, "expiresIn": int(adminAccessTokenTTL.Seconds())} + if a.config.Environment != "production" { + payload["refreshToken"] = newRefreshToken + } + return payload, nil +} + +func (a *App) revokeAdminSession(ctx context.Context, refreshToken string) { + if strings.TrimSpace(refreshToken) == "" { + return + } + hash := sha256.Sum256([]byte(refreshToken)) + _, _ = a.db.ExecContext(ctx, `UPDATE admin_sessions SET revoked_at=NOW(3) WHERE refresh_token_hash=? AND revoked_at IS NULL`, hash[:]) +} + +func (a *App) adminRefresh(w http.ResponseWriter, r *http.Request) { + if !a.rateLimit(w, r, "admin_refresh", clientIP(r), 120, 10*time.Minute) { + return + } + refreshToken := a.adminRefreshTokenFromRequest(r) + if refreshToken == "" { + a.clearAdminRefreshCookie(w) + fail(w, http.StatusUnauthorized, 10001, "刷新令牌不存在") + return + } + payload, err := a.rotateAdminSession(r.Context(), w, r, refreshToken) + if err != nil { + a.clearAdminRefreshCookie(w) + fail(w, http.StatusUnauthorized, 10001, err.Error()) + return + } + reply(w, payload) +} + +func (a *App) adminLogout(w http.ResponseWriter, r *http.Request) { + a.revokeAdminSession(r.Context(), a.adminRefreshTokenFromRequest(r)) + a.clearAdminRefreshCookie(w) + reply(w, map[string]bool{"success": true}) +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_sessions_test.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_sessions_test.go new file mode 100644 index 0000000..9b84792 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/admin_sessions_test.go @@ -0,0 +1,42 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestAdminRefreshCookieSecurityModes(t *testing.T) { + production := &App{config: Config{Environment: "production"}} + recorder := httptest.NewRecorder() + production.setAdminRefreshCookie(recorder, "token", time.Now().Add(time.Hour)) + cookies := recorder.Result().Cookies() + if len(cookies) != 1 || !cookies[0].HttpOnly || !cookies[0].Secure || cookies[0].SameSite != http.SameSiteNoneMode { + t.Fatalf("unexpected production cookie: %#v", cookies) + } + + development := &App{config: Config{Environment: "development"}} + recorder = httptest.NewRecorder() + development.setAdminRefreshCookie(recorder, "token", time.Now().Add(time.Hour)) + cookies = recorder.Result().Cookies() + if len(cookies) != 1 || cookies[0].Secure || cookies[0].SameSite != http.SameSiteLaxMode { + t.Fatalf("unexpected development cookie: %#v", cookies) + } +} + +func TestAdminRefreshTokenRequestFallback(t *testing.T) { + application := &App{} + request := httptest.NewRequest(http.MethodPost, "/admin/v1/auth/refresh", strings.NewReader(`{"refreshToken":"body-token"}`)) + request.Header.Set("Content-Type", "application/json") + if token := application.adminRefreshTokenFromRequest(request); token != "body-token" { + t.Fatalf("unexpected body token %q", token) + } + + request = httptest.NewRequest(http.MethodPost, "/admin/v1/auth/refresh", strings.NewReader(`{"refreshToken":"body-token"}`)) + request.AddCookie(&http.Cookie{Name: adminRefreshCookieKey, Value: "cookie-token"}) + if token := application.adminRefreshTokenFromRequest(request); token != "cookie-token" { + t.Fatalf("cookie token must take priority, got %q", token) + } +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/app.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/app.go new file mode 100644 index 0000000..80c6f32 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/app.go @@ -0,0 +1,475 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "regexp" + "strconv" + "strings" + "time" + + _ "github.com/go-sql-driver/mysql" + "github.com/zeromicro/go-zero/rest" +) + +type Config struct { + Host string + Port int + DSN string + JWTSecret string + ConfigEncryptionKey string + MediaDir string + Environment string + AllowedOrigins []string + SeedDemo bool + BootstrapAdminUsername string + BootstrapAdminPassword string + BootstrapAdminRealName string +} + +type App struct { + config Config + db *sql.DB + hub *Hub +} + +type apiResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data"` + RequestID string `json:"request_id,omitempty"` +} + +type pageResult struct { + Items any `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + Size int `json:"size"` +} + +func LoadConfig() Config { + port, _ := strconv.Atoi(env("IM_PORT", "8888")) + return Config{ + Host: env("IM_HOST", "0.0.0.0"), + Port: port, + DSN: env("IM_DB_DSN", "root:root@tcp(127.0.0.1:3306)/im?charset=utf8mb4&parseTime=True&loc=Local"), + JWTSecret: env("IM_JWT_SECRET", "local-development-secret-change-me"), + ConfigEncryptionKey: env("IM_CONFIG_ENCRYPTION_KEY", ""), + MediaDir: env("IM_MEDIA_DIR", "./uploads"), + Environment: strings.ToLower(env("IM_ENV", "development")), + AllowedOrigins: csvEnv("IM_ALLOWED_ORIGINS", "http://localhost:5173,http://localhost:5174,http://localhost:5180,http://localhost:5555,http://localhost:5556,http://localhost:5560,http://127.0.0.1:5173,http://127.0.0.1:5174,http://127.0.0.1:5180,http://127.0.0.1:5555,http://127.0.0.1:5556,http://127.0.0.1:5560"), + SeedDemo: boolEnv("IM_SEED_DEMO", false), + BootstrapAdminUsername: strings.TrimSpace(os.Getenv("IM_BOOTSTRAP_ADMIN_USERNAME")), + BootstrapAdminPassword: os.Getenv("IM_BOOTSTRAP_ADMIN_PASSWORD"), + BootstrapAdminRealName: env("IM_BOOTSTRAP_ADMIN_REAL_NAME", "平台管理员"), + } +} + +func New(config Config) (*App, error) { + if err := validateConfig(config); err != nil { + return nil, err + } + db, err := sql.Open("mysql", config.DSN) + if err != nil { + return nil, fmt.Errorf("open mysql: %w", err) + } + db.SetMaxOpenConns(30) + db.SetMaxIdleConns(10) + // Keep pooled connections below the local MySQL wait_timeout (120s). + db.SetConnMaxIdleTime(30 * time.Second) + db.SetConnMaxLifetime(90 * time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, fmt.Errorf("connect mysql (run scripts/migrate.ps1 first): %w", err) + } + return &App{config: config, db: db, hub: NewHub()}, nil +} + +func (a *App) Close() { _ = a.db.Close() } + +func (a *App) Run() { + workerContext, stopWorkers := context.WithCancel(context.Background()) + defer stopWorkers() + go func() { + a.processDueAccountClosures(workerContext) + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-workerContext.Done(): + return + case <-ticker.C: + a.processDueAccountClosures(workerContext) + } + } + }() + server := rest.MustNewServer(rest.RestConf{ + Host: a.config.Host, + Port: a.config.Port, + MaxBytes: 16 << 20, + MaxConns: 5_000, + Timeout: 35_000, + }, rest.WithCors(a.config.AllowedOrigins...)) + defer server.Stop() + server.Use(a.requestMetadata) + server.AddRoutes(a.routes()) + log.Printf("星遇 API listening on http://127.0.0.1:%d", a.config.Port) + server.Start() +} + +type responseRecorder struct { + http.ResponseWriter + status int +} + +func (r *responseRecorder) WriteHeader(status int) { + r.status = status + r.ResponseWriter.WriteHeader(status) +} + +var safeRequestID = regexp.MustCompile(`^[A-Za-z0-9_-]{8,64}$`) + +func (a *App) requestMetadata(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + started := time.Now() + requestID := strings.TrimSpace(r.Header.Get("X-Request-ID")) + if !safeRequestID.MatchString(requestID) { + requestID = randomToken()[:32] + } + w.Header().Set("X-Request-ID", requestID) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/admin/") { + w.Header().Set("Cache-Control", "no-store") + } + if r.URL.Path == "/ws" { + next(w, r) + log.Printf("request_id=%s method=%s path=%s status=%d duration_ms=%d ip=%s", requestID, r.Method, r.URL.Path, http.StatusSwitchingProtocols, time.Since(started).Milliseconds(), clientIP(r)) + return + } + recorder := &responseRecorder{ResponseWriter: w, status: http.StatusOK} + next(recorder, r) + log.Printf("request_id=%s method=%s path=%s status=%d duration_ms=%d ip=%s", requestID, r.Method, r.URL.Path, recorder.status, time.Since(started).Milliseconds(), clientIP(r)) + } +} + +func (a *App) routes() []rest.Route { + routes := []rest.Route{ + {Method: http.MethodGet, Path: "/healthz", Handler: a.health}, + {Method: http.MethodPost, Path: "/api/v1/auth/sms/send", Handler: a.sendSMS}, + {Method: http.MethodPost, Path: "/api/v1/auth/register", Handler: a.register}, + {Method: http.MethodPost, Path: "/api/v1/auth/login/password", Handler: a.loginPassword}, + {Method: http.MethodPost, Path: "/api/v1/auth/login/sms", Handler: a.loginSMS}, + {Method: http.MethodGet, Path: "/api/v1/auth/oauth/providers", Handler: a.userOAuthProviders}, + {Method: http.MethodPost, Path: "/api/v1/auth/oauth/start", Handler: a.userOAuthStart}, + {Method: http.MethodPost, Path: "/api/v1/auth/oauth/native", Handler: a.userOAuthNative}, + {Method: http.MethodGet, Path: "/api/v1/auth/oauth/callback", Handler: a.oauthCallback}, + {Method: http.MethodPost, Path: "/api/v1/auth/oauth/exchange", Handler: a.userOAuthExchange}, + {Method: http.MethodPost, Path: "/api/v1/auth/oauth/link", Handler: a.userOAuthLink}, + {Method: http.MethodPost, Path: "/api/v1/auth/password/reset", Handler: a.resetPassword}, + {Method: http.MethodPost, Path: "/api/v1/auth/token/refresh", Handler: a.refreshToken}, + {Method: http.MethodGet, Path: "/api/v1/membership/plans", Handler: a.membershipPlans}, + {Method: http.MethodGet, Path: "/api/v1/payment/channels", Handler: a.paymentChannels}, + {Method: http.MethodPost, Path: "/api/v1/payment/notify", Handler: a.paymentNotify}, + {Method: http.MethodGet, Path: "/api/v1/app/config", Handler: a.appConfig}, + {Method: http.MethodGet, Path: "/uploads/:name", Handler: a.serveMedia}, + {Method: http.MethodHead, Path: "/uploads/:name", Handler: a.serveMedia}, + {Method: http.MethodPost, Path: "/admin/v1/auth/login", Handler: a.adminLogin}, + {Method: http.MethodPost, Path: "/admin/v1/auth/refresh", Handler: a.adminRefresh}, + {Method: http.MethodPost, Path: "/admin/v1/auth/logout", Handler: a.adminLogout}, + {Method: http.MethodGet, Path: "/admin/v1/auth/oauth/providers", Handler: a.adminOAuthProviders}, + {Method: http.MethodPost, Path: "/admin/v1/auth/oauth/start", Handler: a.adminOAuthStart}, + {Method: http.MethodGet, Path: "/admin/v1/auth/oauth/callback", Handler: a.oauthCallback}, + {Method: http.MethodPost, Path: "/admin/v1/auth/oauth/exchange", Handler: a.adminOAuthExchange}, + {Method: http.MethodPost, Path: "/admin/v1/auth/oauth/link", Handler: a.adminOAuthLink}, + } + routes = append(routes, a.userRoutes()...) + routes = append(routes, a.adminRoutes()...) + return routes +} + +func (a *App) userRoutes() []rest.Route { + auth := func(next http.HandlerFunc) http.HandlerFunc { return a.requireAuth("user", next) } + return []rest.Route{ + {Method: http.MethodPost, Path: "/api/v1/auth/logout", Handler: auth(a.logout)}, + {Method: http.MethodGet, Path: "/api/v1/me", Handler: auth(a.me)}, + {Method: http.MethodGet, Path: "/api/v1/me/profile", Handler: auth(a.me)}, + {Method: http.MethodPatch, Path: "/api/v1/me/profile", Handler: auth(a.updateProfile)}, + {Method: http.MethodPut, Path: "/api/v1/me/password", Handler: auth(a.changeUserPassword)}, + {Method: http.MethodPut, Path: "/api/v1/me/phone", Handler: auth(a.changeUserPhone)}, + {Method: http.MethodGet, Path: "/api/v1/me/devices", Handler: auth(a.myDevices)}, + {Method: http.MethodDelete, Path: "/api/v1/me/devices/:id", Handler: auth(a.revokeDevice)}, + {Method: http.MethodGet, Path: "/api/v1/me/blocks", Handler: auth(a.blockedUsers)}, + {Method: http.MethodGet, Path: "/api/v1/me/notification-settings", Handler: auth(a.notificationSettings)}, + {Method: http.MethodPut, Path: "/api/v1/me/notification-settings", Handler: auth(a.updateNotificationSettings)}, + {Method: http.MethodPost, Path: "/api/v1/me/push-tokens", Handler: auth(a.registerPushToken)}, + {Method: http.MethodDelete, Path: "/api/v1/me/push-tokens", Handler: auth(a.deletePushToken)}, + {Method: http.MethodGet, Path: "/api/v1/me/feedback", Handler: auth(a.feedback)}, + {Method: http.MethodPost, Path: "/api/v1/me/feedback", Handler: auth(a.feedback)}, + {Method: http.MethodGet, Path: "/api/v1/me/account-closure", Handler: auth(a.accountClosure)}, + {Method: http.MethodPost, Path: "/api/v1/me/account-closure", Handler: auth(a.accountClosure)}, + {Method: http.MethodDelete, Path: "/api/v1/me/account-closure", Handler: auth(a.accountClosure)}, + {Method: http.MethodGet, Path: "/api/v1/me/data-export", Handler: auth(a.exportMyData)}, + {Method: http.MethodPost, Path: "/api/v1/me/consents", Handler: auth(a.recordConsent)}, + {Method: http.MethodGet, Path: "/api/v1/users/search", Handler: auth(a.searchUsers)}, + {Method: http.MethodGet, Path: "/api/v1/tags", Handler: auth(a.availableTags)}, + {Method: http.MethodGet, Path: "/api/v1/users/:id", Handler: auth(a.userProfile)}, + {Method: http.MethodGet, Path: "/api/v1/me/following", Handler: auth(a.followingList)}, + {Method: http.MethodGet, Path: "/api/v1/me/followers", Handler: auth(a.followerList)}, + {Method: http.MethodGet, Path: "/api/v1/me/visitors", Handler: auth(a.visitorList)}, + {Method: http.MethodGet, Path: "/api/v1/me/privacy", Handler: auth(a.getPrivacy)}, + {Method: http.MethodPut, Path: "/api/v1/me/privacy", Handler: auth(a.updatePrivacy)}, + {Method: http.MethodGet, Path: "/api/v1/me/verification", Handler: auth(a.myVerification)}, + {Method: http.MethodPost, Path: "/api/v1/me/verification", Handler: auth(a.submitVerification)}, + {Method: http.MethodGet, Path: "/api/v1/discover/recommendations", Handler: auth(a.discover)}, + {Method: http.MethodGet, Path: "/api/v1/nearby/users", Handler: auth(a.nearby)}, + {Method: http.MethodPut, Path: "/api/v1/location", Handler: auth(a.updateLocation)}, + {Method: http.MethodPost, Path: "/api/v1/users/:id/follow", Handler: auth(a.follow)}, + {Method: http.MethodDelete, Path: "/api/v1/users/:id/follow", Handler: auth(a.unfollow)}, + {Method: http.MethodPost, Path: "/api/v1/users/:id/like", Handler: auth(a.likeUser)}, + {Method: http.MethodDelete, Path: "/api/v1/users/:id/like", Handler: auth(a.unlikeUser)}, + {Method: http.MethodPost, Path: "/api/v1/users/:id/block", Handler: auth(a.blockUser)}, + {Method: http.MethodDelete, Path: "/api/v1/users/:id/block", Handler: auth(a.unblockUser)}, + {Method: http.MethodGet, Path: "/api/v1/feed", Handler: auth(a.feed)}, + {Method: http.MethodGet, Path: "/api/v1/posts/:id", Handler: auth(a.postDetail)}, + {Method: http.MethodGet, Path: "/api/v1/users/:id/posts", Handler: auth(a.userPosts)}, + {Method: http.MethodPost, Path: "/api/v1/posts", Handler: auth(a.createPost)}, + {Method: http.MethodPatch, Path: "/api/v1/posts/:id", Handler: auth(a.updatePost)}, + {Method: http.MethodDelete, Path: "/api/v1/posts/:id", Handler: auth(a.deleteOwnPost)}, + {Method: http.MethodPost, Path: "/api/v1/media/upload", Handler: auth(a.uploadMedia)}, + {Method: http.MethodPost, Path: "/api/v1/posts/:id/like", Handler: auth(a.likePost)}, + {Method: http.MethodDelete, Path: "/api/v1/posts/:id/like", Handler: auth(a.unlikePost)}, + {Method: http.MethodGet, Path: "/api/v1/posts/:id/comments", Handler: auth(a.comments)}, + {Method: http.MethodPost, Path: "/api/v1/posts/:id/comments", Handler: auth(a.createComment)}, + {Method: http.MethodDelete, Path: "/api/v1/comments/:id", Handler: auth(a.deleteOwnComment)}, + {Method: http.MethodPost, Path: "/api/v1/im/conversations/direct", Handler: auth(a.directConversation)}, + {Method: http.MethodGet, Path: "/api/v1/im/conversations", Handler: auth(a.conversations)}, + {Method: http.MethodGet, Path: "/api/v1/im/conversations/:id/messages", Handler: auth(a.messages)}, + {Method: http.MethodPost, Path: "/api/v1/im/conversations/:id/messages", Handler: auth(a.sendMessageHTTP)}, + {Method: http.MethodPost, Path: "/api/v1/im/messages/:id/recall", Handler: auth(a.recallMessage)}, + {Method: http.MethodPatch, Path: "/api/v1/im/conversations/:id/settings", Handler: auth(a.conversationSettings)}, + {Method: http.MethodGet, Path: "/api/v1/membership/status", Handler: auth(a.membershipStatus)}, + {Method: http.MethodPost, Path: "/api/v1/orders", Handler: auth(a.createOrder)}, + {Method: http.MethodPost, Path: "/api/v1/orders/:id/pay", Handler: auth(a.payOrder)}, + {Method: http.MethodGet, Path: "/api/v1/orders/:id", Handler: auth(a.orderStatus)}, + {Method: http.MethodPost, Path: "/api/v1/orders/:id/close", Handler: auth(a.closeOwnOrder)}, + {Method: http.MethodPost, Path: "/api/v1/orders/:id/refund", Handler: auth(a.requestOrderRefund)}, + {Method: http.MethodGet, Path: "/api/v1/me/orders", Handler: auth(a.myOrders)}, + {Method: http.MethodGet, Path: "/api/v1/notifications", Handler: auth(a.notifications)}, + {Method: http.MethodPost, Path: "/api/v1/notifications/read-all", Handler: auth(a.readAllNotifications)}, + {Method: http.MethodPost, Path: "/api/v1/notifications/:id/read", Handler: auth(a.readNotification)}, + {Method: http.MethodPost, Path: "/api/v1/reports", Handler: auth(a.createReport)}, + {Method: http.MethodGet, Path: "/api/v1/me/reports", Handler: auth(a.myReports)}, + {Method: http.MethodGet, Path: "/ws", Handler: a.websocket}, + } +} + +func (a *App) adminRoutes() []rest.Route { + auth := func(next http.HandlerFunc) http.HandlerFunc { return a.requireAuth("admin", next) } + permit := func(permission string, next http.HandlerFunc) http.HandlerFunc { + return a.requireAdminPermission(permission, next) + } + return []rest.Route{ + {Method: http.MethodPut, Path: "/admin/v1/me/password", Handler: auth(a.adminChangePassword)}, + {Method: http.MethodGet, Path: "/admin/v1/auth/codes", Handler: auth(a.adminCodes)}, + {Method: http.MethodGet, Path: "/admin/v1/user/info", Handler: auth(a.adminInfo)}, + {Method: http.MethodGet, Path: "/admin/v1/dashboard/overview", Handler: permit("dashboard:view", a.dashboard)}, + {Method: http.MethodGet, Path: "/admin/v1/users", Handler: permit("users:view", a.adminUsers)}, + {Method: http.MethodPost, Path: "/admin/v1/users", Handler: permit("users:create", a.adminCreateUser)}, + {Method: http.MethodGet, Path: "/admin/v1/users/:id", Handler: permit("users:view", a.adminUserDetail)}, + {Method: http.MethodPut, Path: "/admin/v1/users/:id/profile", Handler: permit("users:manage", a.adminUpdateUserProfile)}, + {Method: http.MethodPut, Path: "/admin/v1/users/:id/verification", Handler: permit("verification:manage", a.adminUpdateVerification)}, + {Method: http.MethodPut, Path: "/admin/v1/users/:id/membership", Handler: permit("users:manage", a.adminUpdateMembership)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/password-reset", Handler: permit("users:security", a.adminResetUserPassword)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/force-logout", Handler: permit("users:security", a.adminForceLogout)}, + {Method: http.MethodGet, Path: "/admin/v1/users/:id/sanctions", Handler: permit("violations:manage", a.adminUserSanctions)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/sanctions", Handler: permit("violations:manage", a.adminUserSanctions)}, + {Method: http.MethodPost, Path: "/admin/v1/sanctions/:id/revoke", Handler: permit("violations:manage", a.adminRevokeSanction)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/freeze", Handler: permit("violations:manage", a.adminUserStatus)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/unfreeze", Handler: permit("violations:manage", a.adminUserStatus)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/ban", Handler: permit("violations:manage", a.adminUserStatus)}, + {Method: http.MethodPost, Path: "/admin/v1/users/:id/unban", Handler: permit("violations:manage", a.adminUserStatus)}, + {Method: http.MethodGet, Path: "/admin/v1/posts", Handler: permit("content:view", a.adminPosts)}, + {Method: http.MethodGet, Path: "/admin/v1/posts/:id", Handler: permit("content:view", a.adminPostDetail)}, + {Method: http.MethodDelete, Path: "/admin/v1/posts/:id", Handler: permit("content:manage", a.adminDeletePost)}, + {Method: http.MethodGet, Path: "/admin/v1/reports", Handler: permit("reports:handle", a.adminReports)}, + {Method: http.MethodPost, Path: "/admin/v1/reports/:id/handle", Handler: permit("reports:handle", a.adminHandleReport)}, + {Method: http.MethodGet, Path: "/admin/v1/risk/users", Handler: permit("risk:view", a.adminRiskUsers)}, + {Method: http.MethodGet, Path: "/admin/v1/risk/events", Handler: permit("risk:view", a.adminRiskEvents)}, + {Method: http.MethodGet, Path: "/admin/v1/membership/plans", Handler: permit("membership:manage", a.adminPlans)}, + {Method: http.MethodPost, Path: "/admin/v1/membership/plans", Handler: permit("membership:manage", a.adminCreatePlan)}, + {Method: http.MethodPatch, Path: "/admin/v1/membership/plans/:id", Handler: permit("membership:manage", a.adminUpdatePlan)}, + {Method: http.MethodPut, Path: "/admin/v1/membership/plans/:id", Handler: permit("membership:manage", a.adminUpdatePlan)}, + {Method: http.MethodDelete, Path: "/admin/v1/membership/plans/:id", Handler: permit("membership:manage", a.adminDeletePlan)}, + {Method: http.MethodGet, Path: "/admin/v1/orders", Handler: permit("orders:view", a.adminOrders)}, + {Method: http.MethodPut, Path: "/admin/v1/orders/:id", Handler: permit("orders:manage", a.adminUpdateOrder)}, + {Method: http.MethodDelete, Path: "/admin/v1/orders/:id", Handler: permit("orders:manage", a.adminDeleteOrder)}, + {Method: http.MethodPost, Path: "/admin/v1/orders/:id/pay", Handler: permit("orders:manage", a.adminMarkOrderPaid)}, + {Method: http.MethodPost, Path: "/admin/v1/orders/:id/close", Handler: permit("orders:manage", a.adminCloseOrder)}, + {Method: http.MethodPost, Path: "/admin/v1/orders/:id/refund", Handler: permit("orders:manage", a.adminRefund)}, + {Method: http.MethodGet, Path: "/admin/v1/messages", Handler: permit("messages:view", a.adminMessages)}, + {Method: http.MethodPost, Path: "/admin/v1/messages/:id/moderate", Handler: permit("messages:manage", a.adminModerateMessage)}, + {Method: http.MethodGet, Path: "/admin/v1/client-feedback", Handler: permit("users:view", a.adminFeedback)}, + {Method: http.MethodPut, Path: "/admin/v1/client-feedback/:id", Handler: permit("users:manage", a.adminFeedback)}, + {Method: http.MethodGet, Path: "/admin/v1/account-closures", Handler: permit("users:view", a.adminAccountClosures)}, + {Method: http.MethodPut, Path: "/admin/v1/account-closures/:id", Handler: permit("users:manage", a.adminAccountClosures)}, + {Method: http.MethodGet, Path: "/admin/v1/app-versions", Handler: permit("system:manage", a.adminAppVersions)}, + {Method: http.MethodPost, Path: "/admin/v1/app-versions", Handler: permit("system:manage", a.adminAppVersions)}, + {Method: http.MethodPut, Path: "/admin/v1/app-versions/:id", Handler: permit("system:manage", a.adminAppVersions)}, + {Method: http.MethodDelete, Path: "/admin/v1/app-versions/:id", Handler: permit("system:manage", a.adminAppVersions)}, + {Method: http.MethodGet, Path: "/admin/v1/system/configs", Handler: permit("system:manage", a.adminConfigs)}, + {Method: http.MethodPatch, Path: "/admin/v1/system/configs/:key", Handler: permit("system:manage", a.adminUpdateConfig)}, + {Method: http.MethodPut, Path: "/admin/v1/system/configs/:key", Handler: permit("system:manage", a.adminUpdateConfig)}, + {Method: http.MethodGet, Path: "/admin/v1/integrations/:group", Handler: permit("system:manage", a.adminIntegration)}, + {Method: http.MethodPut, Path: "/admin/v1/integrations/:group", Handler: permit("system:manage", a.adminUpdateIntegration)}, + {Method: http.MethodPost, Path: "/admin/v1/integrations/:group/test", Handler: permit("system:manage", a.adminTestIntegration)}, + {Method: http.MethodGet, Path: "/admin/v1/audit-logs", Handler: permit("system:manage", a.adminAuditLogs)}, + {Method: http.MethodGet, Path: "/admin/v1/admin-users", Handler: permit("system:manage", a.adminAccounts)}, + {Method: http.MethodPost, Path: "/admin/v1/admin-users", Handler: permit("system:manage", a.adminCreateAccount)}, + {Method: http.MethodPut, Path: "/admin/v1/admin-users/:id", Handler: permit("system:manage", a.adminUpdateAccount)}, + {Method: http.MethodGet, Path: "/admin/v1/admin-roles", Handler: permit("system:manage", a.adminRoles)}, + {Method: http.MethodPost, Path: "/admin/v1/admin-roles", Handler: permit("system:manage", a.adminCreateRole)}, + {Method: http.MethodPut, Path: "/admin/v1/admin-roles/:id", Handler: permit("system:manage", a.adminUpdateRole)}, + {Method: http.MethodDelete, Path: "/admin/v1/admin-roles/:id", Handler: permit("system:manage", a.adminDeleteRole)}, + {Method: http.MethodGet, Path: "/admin/v1/admin-permissions", Handler: permit("system:manage", a.adminPermissions)}, + } +} + +func (a *App) health(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), time.Second) + defer cancel() + if err := a.db.PingContext(ctx); err != nil { + fail(w, http.StatusServiceUnavailable, 50001, "database unavailable") + return + } + reply(w, map[string]any{"status": "ok", "time": time.Now()}) +} + +func reply(w http.ResponseWriter, data any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(apiResponse{Code: 0, Message: "OK", Data: data}) +} + +func fail(w http.ResponseWriter, status, code int, message string) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(apiResponse{Code: code, Message: message, Data: nil}) +} + +func decode(r *http.Request, out any) error { + decoder := json.NewDecoder(io.LimitReader(r.Body, 2<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(out); err != nil { + return fmt.Errorf("invalid request: %w", err) + } + return nil +} + +func pagination(r *http.Request) (int, int, int) { + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + size, _ := strconv.Atoi(r.URL.Query().Get("size")) + if page < 1 { + page = 1 + } + if size < 1 { + size = 20 + } + if size > 100 { + size = 100 + } + return page, size, (page - 1) * size +} + +func pathID(r *http.Request) (int64, error) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + for i := len(parts) - 1; i >= 0; i-- { + if id, err := strconv.ParseInt(parts[i], 10, 64); err == nil { + return id, nil + } + } + return 0, errors.New("invalid id") +} + +func env(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func boolEnv(key string, fallback bool) bool { + value := strings.TrimSpace(strings.ToLower(os.Getenv(key))) + if value == "" { + return fallback + } + return value == "1" || value == "true" || value == "yes" || value == "on" +} + +func csvEnv(key, fallback string) []string { + value := env(key, fallback) + items := make([]string, 0) + seen := map[string]bool{} + for _, item := range strings.Split(value, ",") { + item = strings.TrimRight(strings.TrimSpace(item), "/") + if item != "" && !seen[item] { + seen[item] = true + items = append(items, item) + } + } + return items +} + +func validateConfig(config Config) error { + if config.Port < 1 || config.Port > 65535 { + return fmt.Errorf("IM_PORT 无效") + } + if config.Environment != "production" { + return nil + } + if config.SeedDemo { + return fmt.Errorf("生产环境禁止启用 IM_SEED_DEMO") + } + if len(config.JWTSecret) < 32 || config.JWTSecret == "local-development-secret-change-me" { + return fmt.Errorf("生产环境必须配置至少 32 字节的 IM_JWT_SECRET") + } + if len(config.ConfigEncryptionKey) < 32 { + return fmt.Errorf("生产环境必须配置至少 32 字节的 IM_CONFIG_ENCRYPTION_KEY") + } + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(config.DSN)), "root:") { + return fmt.Errorf("生产环境禁止使用 root 数据库账号") + } + if len(config.AllowedOrigins) == 0 { + return fmt.Errorf("生产环境必须配置 IM_ALLOWED_ORIGINS") + } + for _, origin := range config.AllowedOrigins { + if origin == "*" { + return fmt.Errorf("生产环境禁止使用通配 CORS 来源") + } + if !strings.HasPrefix(origin, "https://") { + return fmt.Errorf("生产环境来源必须使用 HTTPS: %s", origin) + } + } + return nil +} + +func (a *App) originAllowed(origin string) bool { + origin = strings.TrimRight(strings.TrimSpace(origin), "/") + if origin == "" { + return true + } + for _, allowed := range a.config.AllowedOrigins { + if origin == allowed { + return true + } + } + return false +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/app_oauth.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/app_oauth.go new file mode 100644 index 0000000..03f7caa --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/app_oauth.go @@ -0,0 +1,264 @@ +package app + +import ( + "context" + "crypto/subtle" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +// This URL is registered in the Android/iOS manifest. Never accept a callback +// supplied by an unauthenticated caller (including arbitrary custom schemes). +const appOAuthCallbackURL = "xingyuim://oauth/callback" + +func oauthClientPlatform(raw string) (string, error) { + switch raw { + case "", "h5": + return "h5", nil + case "app": + return "app", nil + default: + return "", errors.New("不支持的登录客户端") + } +} + +func userOAuthEnabledKey(platform, provider string) string { + if platform == "app" { + return "oauth.app." + provider + ".enabled" + } + return "oauth.user." + provider + ".enabled" +} + +func (a *App) appOAuthFrontendURL(ctx context.Context) (string, error) { + raw := a.configPlain(ctx, "oauth.app.frontend_callback_url", "") + if raw != appOAuthCallbackURL { + return "", errors.New("App 回调地址必须为 " + appOAuthCallbackURL) + } + return raw, nil +} + +func validateNativeOAuthConfig(code string, value func(string) string) error { + prefix := "oauth.app." + code + "." + switch code { + case "wechat", "qq": + id := value(prefix + "client_id") + if id == "" || len(id) > 128 { + return fmt.Errorf("App %s AppID 未配置或过长", adminOAuthProviderNames[code]) + } + if code == "wechat" && value(prefix+"client_secret") == "" { + return errors.New("App 微信 AppSecret 未配置") + } + case "google": + ids := strings.Split(value(prefix+"client_ids"), ",") + for _, id := range ids { + id = strings.TrimSpace(id) + if !strings.HasSuffix(id, ".apps.googleusercontent.com") || strings.ContainsAny(id, " \r\n\t/") { + return errors.New("App Google Client ID 列表无效,请用英文逗号分隔 Android/iOS 客户端 ID") + } + } + default: + return errors.New("不支持的 App 原生登录渠道") + } + return nil +} + +func (a *App) appOAuthProvider(ctx context.Context, code string) (adminOAuthProvider, error) { + if code == "github" { + if _, err := a.appOAuthFrontendURL(ctx); err != nil { + return adminOAuthProvider{}, err + } + return a.adminOAuthProvider(ctx, code) + } + value := func(key string) string { return strings.TrimSpace(a.configPlain(ctx, key, "")) } + if err := validateNativeOAuthConfig(code, value); err != nil { + return adminOAuthProvider{}, err + } + p := adminOAuthProvider{Code: code, Name: adminOAuthProviderNames[code], ClientID: value("oauth.app." + code + ".client_id")} + switch code { + case "wechat": + p.ClientSecret = value("oauth.app.wechat.client_secret") + p.TokenURL = "https://api.weixin.qq.com/sns/oauth2/access_token" + p.UserInfoURL = "https://api.weixin.qq.com/sns/userinfo" + case "qq": + p.OpenIDURL = "https://graph.qq.com/oauth2.0/me" + p.UserInfoURL = "https://graph.qq.com/user/get_user_info" + case "google": + p.UserInfoURL = "https://openidconnect.googleapis.com/v1/userinfo" + } + return p, nil +} + +func (a *App) enabledAppOAuthProviders(ctx context.Context) ([]adminOAuthProvider, error) { + providers := []adminOAuthProvider{} + for _, code := range []string{"wechat", "qq", "github", "google"} { + if !a.configBool(ctx, userOAuthEnabledKey("app", code), false) { + continue + } + p, err := a.appOAuthProvider(ctx, code) + if err != nil { + return nil, err + } + providers = append(providers, p) + } + return providers, nil +} + +func (a *App) issueUserOAuthCode(ctx context.Context, provider, platform, scope string, proofHash []byte, identity adminOAuthIdentity) (string, error) { + var userID sql.NullInt64 + err := a.db.QueryRowContext(ctx, `SELECT user_id FROM user_oauth_identities WHERE provider=? AND identity_scope=? AND subject=?`, provider, scope, identity.Subject).Scan(&userID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return "", err + } + code := randomToken() + _, err = a.db.ExecContext(ctx, `INSERT INTO user_oauth_login_codes(code_hash,provider,subject,email,display_name,avatar_url,user_id,expires_at,client_platform,identity_scope,app_proof_hash) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, oauthHash(code), provider, identity.Subject, identity.Email, identity.DisplayName, identity.AvatarURL, userID, time.Now().Add(adminOAuthCodeTTL), platform, scope, proofHash) + return code, err +} + +func validAppOAuthProof(expected []byte, proof string) bool { + return len(expected) == 0 || (len(proof) >= 32 && subtle.ConstantTimeCompare(expected, oauthHash(proof)) == 1) +} + +func (a *App) validateUserOAuthCode(ctx context.Context, code userOAuthLoginCode, proof string) error { + if !validAppOAuthProof(code.AppProofHash, proof) { + return errors.New("授权结果与发起登录的 App 不匹配,请重新登录") + } + if !a.configBool(ctx, userOAuthEnabledKey(code.Platform, code.Provider), false) { + return errors.New("该客户端登录方式已停用") + } + return nil +} + +func (a *App) userOAuthNative(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + Code string `json:"code"` + AccessToken string `json:"accessToken"` + } + if decode(r, &req) != nil || len(req.Code) > 4096 || len(req.AccessToken) > 8192 { + fail(w, 400, 20001, "原生授权参数无效") + return + } + if req.Provider != "wechat" && req.Provider != "qq" && req.Provider != "google" { + fail(w, 400, 20001, "不支持的 App 原生登录渠道") + return + } + if !a.rateLimit(w, r, "user_oauth_native", clientIP(r), 20, 10*time.Minute) { + return + } + if !a.configBool(r.Context(), userOAuthEnabledKey("app", req.Provider), false) { + fail(w, 400, 20001, "该 App 登录方式未启用") + return + } + p, err := a.appOAuthProvider(r.Context(), req.Provider) + if err != nil { + fail(w, 400, 20001, "该 App 登录方式配置不完整") + return + } + var identity adminOAuthIdentity + if req.Provider == "wechat" { + if strings.TrimSpace(req.Code) == "" { + fail(w, 400, 20001, "微信授权码不能为空") + return + } + identity, err = a.fetchAdminOAuthIdentity(r.Context(), p, req.Code, "") + } else { + identity, err = a.fetchNativeTokenIdentity(r.Context(), p, req.AccessToken) + } + if err != nil { + fail(w, 400, 20001, "App 授权验证失败,请检查平台凭证、AppID 和 SDK 配置后重试") + return + } + // OpenID from a mobile application must not collide with a website OpenID. + scope := "" + if p.Code == "wechat" || p.Code == "qq" { + scope = "app:" + p.ClientID + } + code, err := a.issueUserOAuthCode(r.Context(), p.Code, "app", scope, nil, identity) + if err != nil { + fail(w, 500, 50001, "创建 App 登录凭证失败") + return + } + a.cleanupUserOAuthRecords(r.Context()) + w.Header().Set("Cache-Control", "no-store") + reply(w, map[string]string{"oauthCode": code}) +} + +func googleTokenAudienceAllowed(audience, issuedTo, allowed string) bool { + // Both the recipient and the party to which the token was issued must be + // ours. A valid Google token minted for another application is insufficient. + ids := strings.Split(allowed, ",") + for i := range ids { + ids[i] = strings.TrimSpace(ids[i]) + } + return audience != "" && issuedTo != "" && containsString(ids, audience) && containsString(ids, issuedTo) +} + +func (a *App) fetchNativeTokenIdentity(ctx context.Context, p adminOAuthProvider, token string) (adminOAuthIdentity, error) { + if strings.TrimSpace(token) == "" { + return adminOAuthIdentity{}, errors.New("missing access token") + } + if p.Code == "qq" { + var info struct { + ClientID string `json:"client_id"` + OpenID string `json:"openid"` + Error int `json:"error"` + } + if err := a.oauthGetJSON(ctx, p.OpenIDURL, url.Values{"access_token": {token}, "fmt": {"json"}}, &info); err != nil { + return adminOAuthIdentity{}, err + } + if info.Error != 0 || info.OpenID == "" || info.ClientID != p.ClientID { + return adminOAuthIdentity{}, errors.New("QQ token audience mismatch") + } + var profile struct { + Ret int `json:"ret"` + Name string `json:"nickname"` + Picture string `json:"figureurl_qq_2"` + } + if err := a.oauthGetJSON(ctx, p.UserInfoURL, url.Values{"access_token": {token}, "oauth_consumer_key": {p.ClientID}, "openid": {info.OpenID}, "format": {"json"}}, &profile); err != nil { + return adminOAuthIdentity{}, err + } + if profile.Ret != 0 { + return adminOAuthIdentity{}, errors.New("QQ user info rejected") + } + return adminOAuthIdentity{Subject: info.OpenID, DisplayName: profile.Name, AvatarURL: profile.Picture}, nil + } + if p.Code != "google" { + return adminOAuthIdentity{}, errors.New("unsupported token provider") + } + var info struct { + Audience string `json:"audience"` + IssuedTo string `json:"issued_to"` + UserID string `json:"user_id"` + ExpiresIn json.Number `json:"expires_in"` + } + if err := a.oauthGetJSON(ctx, "https://www.googleapis.com/oauth2/v1/tokeninfo", url.Values{"access_token": {token}}, &info); err != nil { + return adminOAuthIdentity{}, err + } + expires, _ := info.ExpiresIn.Int64() + if expires <= 0 || info.UserID == "" || !googleTokenAudienceAllowed(info.Audience, info.IssuedTo, a.configPlain(ctx, "oauth.app.google.client_ids", "")) { + return adminOAuthIdentity{}, errors.New("Google token audience or expiry invalid") + } + var profile struct { + Subject string `json:"sub"` + Name string `json:"name"` + Picture string `json:"picture"` + Email string `json:"email"` + Verified bool `json:"email_verified"` + } + if err := a.oauthBearerJSON(ctx, p.UserInfoURL, token, &profile); err != nil { + return adminOAuthIdentity{}, err + } + if profile.Subject != info.UserID { + return adminOAuthIdentity{}, errors.New("Google token subject mismatch") + } + if !profile.Verified { + profile.Email = "" + } + return adminOAuthIdentity{Subject: profile.Subject, DisplayName: profile.Name, AvatarURL: profile.Picture, Email: profile.Email}, nil +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/app_oauth_test.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/app_oauth_test.go new file mode 100644 index 0000000..e68bcb3 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/app_oauth_test.go @@ -0,0 +1,361 @@ +package app + +import ( + "bytes" + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// Small in-memory SQL adapter keeps these HTTP policy tests independent of a +// developer's database and credentials. Unexpected queries fail the test. +type oauthTestDB struct { + values map[string]string + code userOAuthLoginCode + codeUsed bool + stateHash []byte + stateProvider, stateVerifier, statePlatform string + stateProof []byte + stateUsed bool +} +type oauthTestConnector struct{ db *oauthTestDB } + +func (c oauthTestConnector) Connect(context.Context) (driver.Conn, error) { return c.db, nil } +func (c oauthTestConnector) Driver() driver.Driver { return oauthTestDriver{} } + +type oauthTestDriver struct{} + +func (oauthTestDriver) Open(string) (driver.Conn, error) { return nil, fmt.Errorf("use connector") } +func (*oauthTestDB) Prepare(string) (driver.Stmt, error) { + return nil, fmt.Errorf("unexpected prepare") +} +func (*oauthTestDB) Close() error { return nil } +func (*oauthTestDB) Begin() (driver.Tx, error) { return nil, fmt.Errorf("unexpected transaction") } + +type oauthTestRows struct { + columns []string + values [][]driver.Value +} + +func (r *oauthTestRows) Columns() []string { return r.columns } +func (*oauthTestRows) Close() error { return nil } +func (r *oauthTestRows) Next(dest []driver.Value) error { + if len(r.values) == 0 { + return io.EOF + } + copy(dest, r.values[0]) + r.values = r.values[1:] + return nil +} +func oauthRow(values ...driver.Value) driver.Rows { + columns := make([]string, len(values)) + for i := range columns { + columns[i] = fmt.Sprintf("c%d", i) + } + return &oauthTestRows{columns: columns, values: [][]driver.Value{values}} +} +func (db *oauthTestDB) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { + switch { + case strings.Contains(query, "FROM system_configs"): + value, ok := db.values[args[0].Value.(string)] + if !ok { + return &oauthTestRows{columns: []string{"config_value", "value_type"}}, nil + } + return oauthRow(value, "string"), nil + case strings.Contains(query, "SELECT hits FROM api_rate_limits"): + return oauthRow(int64(1)), nil + case strings.Contains(query, "SELECT provider,code_verifier,client_platform,app_proof_hash FROM user_oauth_states"): + if db.stateUsed || !bytes.Equal(db.stateHash, args[0].Value.([]byte)) { + return &oauthTestRows{columns: []string{"provider", "verifier", "platform", "proof"}}, nil + } + return oauthRow(db.stateProvider, db.stateVerifier, db.statePlatform, db.stateProof), nil + case strings.Contains(query, "SELECT user_id FROM user_oauth_identities"): + return &oauthTestRows{columns: []string{"user_id"}}, nil + case strings.Contains(query, "FROM user_oauth_login_codes"): + c := db.code + return oauthRow(c.Provider, c.Subject, c.Email, c.DisplayName, c.AvatarURL, nil, c.Platform, c.IdentityScope, c.AppProofHash), nil + case strings.Contains(query, "SELECT p.nickname,u.status"): + return oauthRow("tester", int64(1)), nil + default: + return nil, fmt.Errorf("unexpected query: %s", query) + } +} +func (db *oauthTestDB) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + switch { + case strings.Contains(query, "INSERT INTO user_oauth_states"): + db.stateHash = args[0].Value.([]byte) + db.stateProvider, db.stateVerifier, db.statePlatform = args[1].Value.(string), args[2].Value.(string), args[4].Value.(string) + db.stateProof, _ = args[5].Value.([]byte) + return driver.RowsAffected(1), nil + case strings.Contains(query, "UPDATE user_oauth_states"): + if db.stateUsed || !bytes.Equal(db.stateHash, args[0].Value.([]byte)) { + return driver.RowsAffected(0), nil + } + db.stateUsed = true + return driver.RowsAffected(1), nil + case strings.Contains(query, "INSERT INTO user_oauth_login_codes"): + db.code.Provider, db.code.Subject, db.code.Platform, db.code.IdentityScope = args[1].Value.(string), args[2].Value.(string), args[8].Value.(string), args[9].Value.(string) + db.code.AppProofHash, _ = args[10].Value.([]byte) + return driver.RowsAffected(1), nil + case strings.HasPrefix(query, "DELETE FROM user_oauth_"): + return driver.RowsAffected(0), nil + case strings.Contains(query, "api_rate_limits"), strings.Contains(query, "UPDATE user_oauth_identities SET last_login_at"): + return driver.RowsAffected(1), nil + case strings.Contains(query, "UPDATE user_oauth_login_codes SET used_at"): + if db.codeUsed { + return driver.RowsAffected(0), nil + } + db.codeUsed = true + return driver.RowsAffected(1), nil + default: + return nil, fmt.Errorf("unexpected exec: %s", query) + } +} +func oauthTestApp(t *testing.T, values map[string]string) (*App, *oauthTestDB) { + t.Helper() + store := &oauthTestDB{values: values} + db := sql.OpenDB(oauthTestConnector{store}) + t.Cleanup(func() { _ = db.Close() }) + return &App{db: db, config: Config{Environment: "development"}}, store +} +func oauthPost(handler http.HandlerFunc, body string) *httptest.ResponseRecorder { + r := httptest.NewRequest("POST", "/api/v1/auth/oauth/test", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler(w, r) + return w +} + +func TestAppOAuthProviderSwitchesAndConfiguration(t *testing.T) { + app, store := oauthTestApp(t, map[string]string{ + "oauth.user.qq.enabled": "true", "oauth.app.qq.enabled": "false", + "oauth.app.qq.client_id": "mobile-id", "oauth.app.wechat.enabled": "true", + }) + list := func(platform string) string { + w := httptest.NewRecorder() + app.userOAuthProviders(w, httptest.NewRequest("GET", "/?platform="+platform, nil)) + return w.Body.String() + } + if strings.Contains(list("app"), `"code":"qq"`) { + t.Fatal("H5 switch must not enable App") + } + store.values["oauth.app.qq.enabled"] = "true" + body := list("app") + if !strings.Contains(body, `"code":"qq"`) || strings.Contains(body, `"code":"wechat"`) { + t.Fatalf("only complete, enabled channels should be returned: %s", body) + } + if strings.Contains(body, "mobile-id") { + t.Fatal("provider discovery must not return credentials") + } + w := httptest.NewRecorder() + app.userOAuthProviders(w, httptest.NewRequest("GET", "/?platform=unknown", nil)) + if w.Code != 400 { + t.Fatal("unknown platform must be rejected") + } +} + +func TestDisabledAppOAuthBlocksEveryLoginEntry(t *testing.T) { + app, store := oauthTestApp(t, map[string]string{"oauth.user.qq.enabled": "true", "oauth.app.qq.enabled": "false"}) + store.code = userOAuthLoginCode{Provider: "qq", Platform: "app", Subject: "openid"} + for name, handler := range map[string]http.HandlerFunc{"native": app.userOAuthNative, "exchange": app.userOAuthExchange, "link": app.userOAuthLink} { + bodies := map[string]string{ + "native": `{"provider":"qq","accessToken":"token"}`, + "exchange": `{"code":"ticket"}`, + "link": `{"code":"ticket","phone":"13800138000","smsCode":"123456"}`, + } + w := oauthPost(handler, bodies[name]) + if w.Code != 400 || !(strings.Contains(w.Body.String(), "停用") || strings.Contains(w.Body.String(), "未启用")) { + t.Fatalf("%s accepted a disabled App provider: %d %s", name, w.Code, w.Body.String()) + } + } +} + +func TestAppOAuthCallbackProofAndCodeReplay(t *testing.T) { + proof := strings.Repeat("p", 43) + app, store := oauthTestApp(t, map[string]string{"oauth.app.github.enabled": "true"}) + store.code = userOAuthLoginCode{Provider: "github", Platform: "app", AppProofHash: oauthHash(proof)} + for _, supplied := range []string{"", strings.Repeat("x", 43)} { + w := oauthPost(app.userOAuthExchange, `{"code":"intercepted-ticket","appProof":"`+supplied+`"}`) + if w.Code != 400 { + t.Fatal("intercepted callback must not authorize a different App instance") + } + } + w := oauthPost(app.userOAuthExchange, `{"code":"ticket","appProof":"`+proof+`"}`) + if w.Code != 200 || !strings.Contains(w.Body.String(), `"requiresLink":true`) { + t.Fatalf("correct proof rejected: %s", w.Body.String()) + } + if _, _, err := app.consumeUserOAuthCode(context.Background(), "ticket", 42); err != nil { + t.Fatal(err) + } + if _, _, err := app.consumeUserOAuthCode(context.Background(), "ticket", 42); err == nil { + t.Fatal("login ticket reused") + } +} + +func TestAppOAuthConfigurationAndRouting(t *testing.T) { + values := map[string]string{ + "oauth.admin.frontend_callback_url": "http://localhost:5560/auth/social-callback", + "oauth.user.frontend_callback_url": "http://localhost:5174/#/pages/auth/oauth-callback", + "oauth.app.qq.enabled": "true", "oauth.app.qq.client_id": "mobile-id", + } + app, _ := oauthTestApp(t, map[string]string{}) + app.config.Environment = "production" + if err := app.validateAdminOAuthConfigValues(context.Background(), values, nil); err != nil { + t.Fatalf("native-only config should not require QQ website credentials: %v", err) + } + delete(values, "oauth.app.qq.client_id") + if err := app.validateAdminOAuthConfigValues(context.Background(), values, nil); err == nil { + t.Fatal("enabled incomplete native config accepted") + } + if validAppOAuthProof(oauthHash("required"), "") { + t.Fatal("missing callback proof accepted") + } + if googleTokenAudienceAllowed("other", "other", "ours") || googleTokenAudienceAllowed("ours", "other", "ours") { + t.Fatal("foreign Google application accepted") + } + if !googleTokenAudienceAllowed("android", "ios", "android, ios") { + t.Fatal("allowed app clients rejected") + } + app, _ = oauthTestApp(t, map[string]string{"oauth.app.frontend_callback_url": "javascript:alert(1)"}) + if _, err := app.appOAuthFrontendURL(context.Background()); err == nil { + t.Fatal("unsafe callback accepted") + } +} + +type oauthRoundTripper func(*http.Request) (*http.Response, error) + +func (f oauthRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } +func mockOAuthHTTP(t *testing.T, responder func(*http.Request) string) { + t.Helper() + original := http.DefaultTransport + http.DefaultTransport = oauthRoundTripper(func(r *http.Request) (*http.Response, error) { + if r.URL.Scheme != "https" { + t.Fatal("credentials sent over insecure transport") + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(responder(r)))}, nil + }) + t.Cleanup(func() { http.DefaultTransport = original }) +} + +func TestNativeQQValidatesTokenOwner(t *testing.T) { + app := &App{} + clientID := "foreign-app" + profileCalls := 0 + mockOAuthHTTP(t, func(r *http.Request) string { + if r.URL.Path == "/oauth2.0/me" { + return `{"openid":"verified-user","client_id":"` + clientID + `"}` + } + profileCalls++ + if r.URL.Query().Get("openid") != "verified-user" { + t.Fatal("unverified OpenID used") + } + return `{"ret":0,"nickname":"tester","figureurl_qq_2":"https://example.com/avatar.png"}` + }) + p := adminOAuthProvider{Code: "qq", ClientID: "our-app", OpenIDURL: "https://graph.qq.com/oauth2.0/me", UserInfoURL: "https://graph.qq.com/user/get_user_info"} + if _, err := app.fetchNativeTokenIdentity(context.Background(), p, "token"); err == nil || profileCalls != 0 { + t.Fatal("foreign QQ token was accepted") + } + clientID = "our-app" + identity, err := app.fetchNativeTokenIdentity(context.Background(), p, "token") + if err != nil || identity.Subject != "verified-user" { + t.Fatalf("valid QQ token rejected: %v", err) + } +} + +func TestNativeGoogleChecksAudienceExpiryAndSubject(t *testing.T) { + app, _ := oauthTestApp(t, map[string]string{"oauth.app.google.client_ids": "our-app"}) + audience, expires, subject := "other-app", "3600", "verified-user" + mockOAuthHTTP(t, func(r *http.Request) string { + if r.URL.Path == "/oauth2/v1/tokeninfo" { + return `{"audience":"` + audience + `","issued_to":"` + audience + `","user_id":"verified-user","expires_in":` + expires + `}` + } + if r.Header.Get("Authorization") != "Bearer token" { + t.Fatal("missing bearer token") + } + return `{"sub":"` + subject + `","name":"tester","email":"unverified@example.com","email_verified":false}` + }) + p := adminOAuthProvider{Code: "google", UserInfoURL: "https://openidconnect.googleapis.com/v1/userinfo"} + checkRejected := func() { + t.Helper() + if _, err := app.fetchNativeTokenIdentity(context.Background(), p, "token"); err == nil { + t.Fatal("invalid Google token accepted") + } + } + checkRejected() + audience, expires = "our-app", "0" + checkRejected() + expires, subject = "3600", "another-user" + checkRejected() + subject = "verified-user" + identity, err := app.fetchNativeTokenIdentity(context.Background(), p, "token") + if err != nil || identity.Subject != subject || identity.Email != "" { + t.Fatalf("verified Google token failed: %#v %v", identity, err) + } +} + +func TestAppGitHubAuthorizationRoundTrip(t *testing.T) { + app, store := oauthTestApp(t, map[string]string{ + "oauth.app.github.enabled": "true", + "oauth.app.frontend_callback_url": appOAuthCallbackURL, + "oauth.github.client_id": "our-github-app", + "oauth.github.client_secret": "server-secret", + "oauth.github.authorization_url": "https://github.com/login/oauth/authorize", + "oauth.github.token_url": "https://github.com/login/oauth/access_token", + "oauth.github.userinfo_url": "https://api.github.com/user", + "oauth.github.scope": "read:user", + "oauth.github.redirect_uri": "https://api.example.com/api/v1/auth/oauth/callback", + }) + mockOAuthHTTP(t, func(r *http.Request) string { + if r.URL.Path == "/login/oauth/access_token" { + _ = r.ParseForm() + if r.Form.Get("code_verifier") != store.stateVerifier || r.Form.Get("code") != "provider-code" { + t.Fatal("missing PKCE verifier or code") + } + return `{"access_token":"provider-token"}` + } + return `{"id":123,"login":"tester"}` + }) + w := oauthPost(app.userOAuthStart, `{"provider":"github","platform":"app"}`) + var start struct { + Data map[string]string `json:"data"` + } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &start) != nil { + t.Fatalf("start failed: %s", w.Body.String()) + } + authURL, _ := url.Parse(start.Data["authorizationUrl"]) + if authURL.Query().Get("state") != start.Data["requestId"] || authURL.Query().Get("code_challenge") != pkceChallenge(store.stateVerifier) { + t.Fatal("authorization is not bound to the request") + } + if !bytes.Equal(store.stateProof, oauthHash(start.Data["appProof"])) { + t.Fatal("proof not hashed at rest") + } + callback := "/api/v1/auth/oauth/callback?state=" + url.QueryEscape(start.Data["requestId"]) + "&code=provider-code" + w = httptest.NewRecorder() + app.userOAuthCallback(w, httptest.NewRequest("GET", callback, nil)) + resultURL, err := url.Parse(w.Header().Get("Location")) + if w.Code != 302 || err != nil || resultURL.Scheme != "xingyuim" { + t.Fatalf("App callback failed: %d %s", w.Code, w.Body.String()) + } + if resultURL.Query().Get("requestId") != start.Data["requestId"] || resultURL.Query().Get("oauthCode") == "" { + t.Fatal("App callback missing correlation or ticket") + } + if strings.Contains(resultURL.String(), start.Data["appProof"]) { + t.Fatal("private proof leaked into browser redirect") + } + if store.code.Platform != "app" || !bytes.Equal(store.code.AppProofHash, store.stateProof) { + t.Fatal("login ticket lost its App binding") + } + w = httptest.NewRecorder() + app.userOAuthCallback(w, httptest.NewRequest("GET", callback, nil)) + if w.Code != 400 { + t.Fatal("authorization callback replay accepted") + } +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/auth.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/auth.go new file mode 100644 index 0000000..7841fbd --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/auth.go @@ -0,0 +1,296 @@ +package app + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/base64" + "encoding/hex" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/bcrypt" +) + +type identity struct { + ID int64 + Role string + Name string + IssuedAt time.Time + Version int +} + +type identityKey struct{} + +type tokenClaims struct { + Role string `json:"role"` + Name string `json:"name"` + Version int `json:"ver,omitempty"` + jwt.RegisteredClaims +} + +func (a *App) token(id int64, role, name string, ttl time.Duration) (string, error) { + now := time.Now() + version := 0 + if role == "user" { + _ = a.db.QueryRow(`SELECT token_version FROM user_security_controls WHERE user_id=?`, id).Scan(&version) + } else if role == "admin" { + _ = a.db.QueryRow(`SELECT token_version FROM admin_users WHERE id=?`, id).Scan(&version) + } + claims := tokenClaims{ + Role: role, + Name: name, + Version: version, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: strconv.FormatInt(id, 10), + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(a.config.JWTSecret)) +} + +func (a *App) parseToken(raw string) (identity, error) { + parsed, err := jwt.ParseWithClaims(raw, &tokenClaims{}, func(t *jwt.Token) (any, error) { + if t.Method != jwt.SigningMethodHS256 { + return nil, fmt.Errorf("unexpected signing method") + } + return []byte(a.config.JWTSecret), nil + }) + if err != nil || !parsed.Valid { + return identity{}, fmt.Errorf("invalid token") + } + claims, ok := parsed.Claims.(*tokenClaims) + if !ok { + return identity{}, fmt.Errorf("invalid claims") + } + id, err := strconv.ParseInt(claims.Subject, 10, 64) + if err != nil { + return identity{}, fmt.Errorf("invalid subject") + } + issuedAt := time.Time{} + if claims.IssuedAt != nil { + issuedAt = claims.IssuedAt.Time + } + return identity{ID: id, Role: claims.Role, Name: claims.Name, IssuedAt: issuedAt, Version: claims.Version}, nil +} + +func (a *App) requireAuth(role string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + raw := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + if raw == "" { + fail(w, http.StatusUnauthorized, 10001, "请先登录") + return + } + who, err := a.parseToken(raw) + if err != nil || who.Role != role { + fail(w, http.StatusUnauthorized, 10001, "登录状态已失效") + return + } + if role == "user" { + if a.executeDueAccountClosure(r, who.ID) { + fail(w, http.StatusUnauthorized, 10001, "账号已按注销申请完成删除") + return + } + var status int + if err = a.db.QueryRowContext(r.Context(), `SELECT status FROM users WHERE id=? AND deleted_at IS NULL`, who.ID).Scan(&status); err == nil { + status = a.normalizeUserStatus(r.Context(), who.ID, status) + } + if err != nil || status != 1 { + fail(w, http.StatusForbidden, 10006, "账号已被冻结或封禁") + return + } + var forceLogout sql.NullTime + var tokenVersion int + _ = a.db.QueryRowContext(r.Context(), `SELECT force_logout_at,token_version FROM user_security_controls WHERE user_id=?`, who.ID).Scan(&forceLogout, &tokenVersion) + if who.Version != tokenVersion || (forceLogout.Valid && !who.IssuedAt.IsZero() && who.IssuedAt.Unix() < forceLogout.Time.Unix()) { + fail(w, http.StatusUnauthorized, 10001, "登录状态已失效,请重新登录") + return + } + } else if role == "admin" { + var status, tokenVersion int + if err = a.db.QueryRowContext(r.Context(), `SELECT status,token_version FROM admin_users WHERE id=?`, who.ID).Scan(&status, &tokenVersion); err != nil || status != 1 { + fail(w, http.StatusForbidden, 10006, "管理员账号已停用") + return + } + if who.Version != tokenVersion { + fail(w, http.StatusUnauthorized, 10001, "密码已修改,请重新登录") + return + } + } + next(w, r.WithContext(context.WithValue(r.Context(), identityKey{}, who))) + } +} + +func current(r *http.Request) identity { + who, _ := r.Context().Value(identityKey{}).(identity) + return who +} + +// Keep existing bcrypt credentials compatible. Long passwords use Argon2id so +// removing the UI limits never truncates a password or fails at bcrypt's limit. +// Parameters follow OWASP's 19 MiB / 2 iterations / 1 thread recommendation. +const longPasswordHashPrefix = "$argon2id$v=19$m=19456,t=2,p=1$" + +func hashPassword(password string) (string, error) { + if len(password) > 72 { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + key := argon2.IDKey([]byte(password), salt, 2, 19*1024, 1, 32) + return longPasswordHashPrefix + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(key), nil + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(hash), err +} + +func checkPassword(hash, password string) bool { + if strings.HasPrefix(hash, "$argon2id$") { + // Only our supported parameters are accepted; never allocate memory from + // unchecked parameters in a corrupt or externally supplied hash. + payload, ok := strings.CutPrefix(hash, longPasswordHashPrefix) + if !ok || len(payload) != 66 { + return false + } + parts := strings.Split(payload, "$") + if len(parts) != 2 { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[0]) + if err != nil || len(salt) != 16 { + return false + } + expected, err := base64.RawStdEncoding.DecodeString(parts[1]) + if err != nil || len(expected) != 32 { + return false + } + key := argon2.IDKey([]byte(password), salt, 2, 19*1024, 1, 32) + return subtle.ConstantTimeCompare(key, expected) == 1 + } + if len(password) > 72 { + return false + } + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +func validUserPassword(password string) bool { + return password != "" +} + +func validPhone(value string) bool { + value = strings.TrimSpace(value) + if len(value) != 11 || value[0] != '1' || value[1] < '3' || value[1] > '9' { + return false + } + for _, char := range value { + if char < '0' || char > '9' { + return false + } + } + return true +} + +func phoneHash(phone string) []byte { + sum := sha256.Sum256([]byte(strings.TrimSpace(phone))) + return sum[:] +} + +var encryptedPhonePrefix = []byte("enc:v1:") + +func (a *App) piiEncryptionKey() [32]byte { + key := a.config.ConfigEncryptionKey + if key == "" { + key = a.config.JWTSecret + ":development-pii" + } + return sha256.Sum256([]byte(key + ":phone")) +} + +func (a *App) encryptPhone(phone string) ([]byte, error) { + key := a.piiEncryptionKey() + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err = rand.Read(nonce); err != nil { + return nil, err + } + payload := append(append([]byte{}, encryptedPhonePrefix...), nonce...) + payload = gcm.Seal(payload, nonce, []byte(strings.TrimSpace(phone)), nil) + return payload, nil +} + +func (a *App) decryptPhone(payload []byte) (string, error) { + if !bytes.HasPrefix(payload, encryptedPhonePrefix) { + return string(payload), nil + } + key := a.piiEncryptionKey() + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + ciphertext := payload[len(encryptedPhonePrefix):] + if len(ciphertext) < gcm.NonceSize() { + return "", fmt.Errorf("invalid encrypted phone") + } + plain, err := gcm.Open(nil, ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():], nil) + return string(plain), err +} + +func (a *App) encryptLegacyPhones(ctx context.Context) error { + rows, err := a.db.QueryContext(ctx, `SELECT id,phone_cipher FROM users WHERE phone_cipher IS NOT NULL AND LEFT(phone_cipher,7)<>?`, encryptedPhonePrefix) + if err != nil { + return err + } + type legacyPhone struct { + id int64 + payload []byte + } + items := []legacyPhone{} + for rows.Next() { + var item legacyPhone + if err = rows.Scan(&item.id, &item.payload); err != nil { + _ = rows.Close() + return err + } + items = append(items, item) + } + if err = rows.Close(); err != nil { + return err + } + for _, item := range items { + encrypted, encryptErr := a.encryptPhone(string(item.payload)) + if encryptErr != nil { + return encryptErr + } + if _, err = a.db.ExecContext(ctx, `UPDATE users SET phone_cipher=? WHERE id=? AND phone_cipher=?`, encrypted, item.id, item.payload); err != nil { + return err + } + } + return nil +} + +func randomToken() string { + buf := make([]byte, 32) + _, _ = rand.Read(buf) + return hex.EncodeToString(buf) +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/avatar_images.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/avatar_images.go new file mode 100644 index 0000000..21c7b4b --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/avatar_images.go @@ -0,0 +1,83 @@ +package app + +import ( + "bytes" + "context" + "errors" + "image" + "image/color" + "image/draw" + "image/jpeg" + "io" + + "github.com/disintegration/imaging" + _ "golang.org/x/image/webp" +) + +const ( + avatarDisplaySize = 640 + avatarThumbnailSize = 256 + maxAvatarPixels = 24_000_000 +) + +// Bound peak decoding memory on small servers. Do not queue decoded photos in +// memory when multiple clients upload avatars at the same time. +var avatarProcessing = make(chan struct{}, 1) +var errAvatarBusy = errors.New("头像正在处理中,请稍后重试") + +type avatarImages struct { + Display, Thumbnail []byte + Width, Height int +} + +func createAvatarImages(ctx context.Context, source io.ReadSeeker) (avatarImages, error) { + select { + case avatarProcessing <- struct{}{}: + defer func() { <-avatarProcessing }() + default: + return avatarImages{}, errAvatarBusy + } + if err := ctx.Err(); err != nil { + return avatarImages{}, err + } + config, format, err := image.DecodeConfig(source) + if err != nil || (format != "jpeg" && format != "png" && format != "gif" && format != "webp") { + return avatarImages{}, errors.New("无法读取头像,请选择有效的 JPG、PNG、GIF 或 WebP 图片") + } + if config.Width <= 0 || config.Height <= 0 || config.Width > 16384 || config.Height > 16384 || int64(config.Width)*int64(config.Height) > maxAvatarPixels { + return avatarImages{}, errors.New("头像分辨率过大,请选择不超过 2400 万像素的图片") + } + if _, err = source.Seek(0, io.SeekStart); err != nil { + return avatarImages{}, err + } + // Orient phone photos before resizing. Re-encoding also removes EXIF/GPS + // from the display variants. Animated images use their first decoded frame. + src, err := imaging.Decode(source, imaging.AutoOrientation(true)) + if err != nil { + return avatarImages{}, errors.New("头像文件损坏或格式暂不支持,请重新选择图片") + } + if err = ctx.Err(); err != nil { + return avatarImages{}, err + } + display := imaging.Fit(src, avatarDisplaySize, avatarDisplaySize, imaging.Lanczos) + thumbnail := imaging.Fit(display, avatarThumbnailSize, avatarThumbnailSize, imaging.Lanczos) + result := avatarImages{Width: display.Bounds().Dx(), Height: display.Bounds().Dy()} + if result.Display, err = avatarJPEG(display, 82); err != nil { + return avatarImages{}, err + } + if result.Thumbnail, err = avatarJPEG(thumbnail, 78); err != nil { + return avatarImages{}, err + } + _, err = source.Seek(0, io.SeekStart) + return result, err +} + +func avatarJPEG(src image.Image, quality int) ([]byte, error) { + // Composite transparency onto white instead of producing black PNG edges. + opaque := image.NewRGBA(src.Bounds()) + draw.Draw(opaque, opaque.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) + draw.Draw(opaque, opaque.Bounds(), src, src.Bounds().Min, draw.Over) + var buffer bytes.Buffer + err := jpeg.Encode(&buffer, opaque, &jpeg.Options{Quality: quality}) + return buffer.Bytes(), err +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/avatar_images_test.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/avatar_images_test.go new file mode 100644 index 0000000..b747c11 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/avatar_images_test.go @@ -0,0 +1,182 @@ +package app + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/binary" + "errors" + "hash/crc32" + "image" + "image/color" + "image/gif" + "image/jpeg" + "image/png" + "io" + "os" + "path/filepath" + "testing" +) + +func avatarPNG(t *testing.T, width, height int) []byte { + t.Helper() + img := image.NewNRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.SetNRGBA(x, y, color.NRGBA{uint8(x*31 + y*7), uint8(x + y*19), uint8(x*5 + y), 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestAvatarVariantsDimensionsAndSource(t *testing.T) { + source := avatarPNG(t, 1800, 1200) + reader := bytes.NewReader(source) + result, err := createAvatarImages(context.Background(), reader) + if err != nil { + t.Fatal(err) + } + for _, item := range []struct { + data []byte + max int + }{{result.Display, 640}, {result.Thumbnail, 256}} { + cfg, format, err := image.DecodeConfig(bytes.NewReader(item.data)) + if err != nil || format != "jpeg" || cfg.Width != item.max || cfg.Height != item.max*2/3 { + t.Fatalf("unexpected avatar dimensions: %+v %s %v", cfg, format, err) + } + } + if result.Width != 640 || result.Height != 426 { + t.Fatalf("unexpected display metadata: %+v", result) + } + preserved, _ := io.ReadAll(reader) + if !bytes.Equal(source, preserved) { + t.Fatal("source bytes or file offset changed") + } + t.Logf("PNG %d bytes -> display %d bytes, thumbnail %d bytes", len(source), len(result.Display), len(result.Thumbnail)) +} + +func TestAvatarSmallTransparentImageIsNotEnlarged(t *testing.T) { + var buf bytes.Buffer + if err := png.Encode(&buf, image.NewNRGBA(image.Rect(0, 0, 32, 16))); err != nil { + t.Fatal(err) + } + result, err := createAvatarImages(context.Background(), bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatal(err) + } + for _, data := range [][]byte{result.Display, result.Thumbnail} { + img, err := jpeg.Decode(bytes.NewReader(data)) + if err != nil || img.Bounds().Dx() != 32 || img.Bounds().Dy() != 16 { + t.Fatal("small avatar was enlarged", err) + } + r, g, b, _ := img.At(5, 5).RGBA() + if r < 64000 || g < 64000 || b < 64000 { + t.Fatal("transparency must be composited onto white") + } + } +} + +func TestAvatarPhoneEXIFOrientation(t *testing.T) { + img := image.NewNRGBA(image.Rect(0, 0, 400, 200)) + for y := 0; y < 200; y++ { + for x := 0; x < 400; x++ { + c := color.NRGBA{R: 255, A: 255} + if x >= 200 { + c = color.NRGBA{B: 255, A: 255} + } + img.SetNRGBA(x, y, c) + } + } + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 95}); err != nil { + t.Fatal(err) + } + // JPEG APP1 containing a little-endian TIFF orientation=6 (90 degrees CW). + exif := []byte{'E', 'x', 'i', 'f', 0, 0, 'I', 'I', 42, 0, 8, 0, 0, 0, 1, 0, 0x12, 1, 3, 0, 1, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0} + source := append([]byte{0xff, 0xd8, 0xff, 0xe1, 0, byte(len(exif) + 2)}, exif...) + source = append(source, buf.Bytes()[2:]...) + result, err := createAvatarImages(context.Background(), bytes.NewReader(source)) + if err != nil { + t.Fatal(err) + } + output, err := jpeg.Decode(bytes.NewReader(result.Display)) + if err != nil || output.Bounds().Dx() != 200 || output.Bounds().Dy() != 400 { + t.Fatal("orientation was not applied", err) + } + r, _, b, _ := output.At(100, 50).RGBA() + if r <= b { + t.Fatal("top half should be red") + } + r, _, b, _ = output.At(100, 350).RGBA() + if b <= r { + t.Fatal("bottom half should be blue") + } + if bytes.Contains(result.Display, []byte("Exif")) { + t.Fatal("display variant retained EXIF") + } +} + +func TestAvatarRejectsInvalidAndExcessivePixels(t *testing.T) { + valid := avatarPNG(t, 2, 2) + huge := append([]byte(nil), valid...) + binary.BigEndian.PutUint32(huge[16:20], 7000) + binary.BigEndian.PutUint32(huge[20:24], 7000) + binary.BigEndian.PutUint32(huge[29:33], crc32.ChecksumIEEE(huge[12:29])) + for name, source := range map[string][]byte{"not image": []byte("not an image"), "truncated": valid[:33], "decompression bomb": huge} { + t.Run(name, func(t *testing.T) { + if _, err := createAvatarImages(context.Background(), bytes.NewReader(source)); err == nil { + t.Fatal("invalid image accepted") + } + }) + } + avatarProcessing <- struct{}{} + _, err := createAvatarImages(context.Background(), bytes.NewReader(valid)) + <-avatarProcessing + if !errors.Is(err, errAvatarBusy) { + t.Fatalf("busy decoder should reject without queuing: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err = createAvatarImages(ctx, bytes.NewReader(valid)); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } +} + +func TestAvatarGIFAndWebP(t *testing.T) { + var gifBuf bytes.Buffer + if err := gif.Encode(&gifBuf, image.NewNRGBA(image.Rect(0, 0, 20, 10)), nil); err != nil { + t.Fatal(err) + } + webp, err := base64.StdEncoding.DecodeString("UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==") + if err != nil { + t.Fatal(err) + } + for _, source := range [][]byte{gifBuf.Bytes(), webp} { + if _, err := createAvatarImages(context.Background(), bytes.NewReader(source)); err != nil { + t.Fatal(err) + } + } +} + +func TestAvatarPhotoFixtureSize(t *testing.T) { + files, err := filepath.Glob(filepath.Join("..", "..", "..", "fixtures", "test-users", "avatars", "*.png")) + if err != nil || len(files) == 0 { + t.Skip("optional portrait fixtures are not included") + } + source, err := os.ReadFile(files[0]) + if err != nil { + t.Fatal(err) + } + result, err := createAvatarImages(context.Background(), bytes.NewReader(source)) + if err != nil { + t.Fatal(err) + } + if len(result.Thumbnail)*10 >= len(source) || len(result.Display)*10 >= len(source) { + t.Fatal("portrait fixture should be substantially reduced") + } + t.Logf("portrait %d bytes -> display %d bytes; thumbnail %d bytes (%.1f%% smaller)", len(source), len(result.Display), len(result.Thumbnail), 100*(1-float64(len(result.Thumbnail))/float64(len(source)))) +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/client_productization.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/client_productization.go new file mode 100644 index 0000000..ed2fb3d --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/client_productization.go @@ -0,0 +1,751 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +type membershipEntitlements struct { + DailyActiveChatLimit int `json:"dailyActiveChatLimit"` + DailyLikeLimit int `json:"dailyLikeLimit"` + CanViewVisitors bool `json:"canViewVisitors"` + CanInvisibleVisit bool `json:"canInvisibleVisit"` + RecommendationWeight int `json:"recommendationWeight"` +} + +func (a *App) validateOwnedImageEvidence(ctx context.Context, userID int64, rawURLs []string) ([]string, error) { + items := make([]string, 0, len(rawURLs)) + seen := map[string]bool{} + for _, rawURL := range rawURLs { + mediaURL := strings.TrimSpace(rawURL) + if mediaURL == "" || seen[mediaURL] { + return nil, fmt.Errorf("证据图片地址无效或重复") + } + seen[mediaURL] = true + var exists int + if err := a.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM media_assets WHERE owner_user_id=? AND public_url=? AND media_type='image' AND status=1 AND moderation_status=1)`, userID, mediaURL).Scan(&exists); err != nil || exists != 1 { + return nil, fmt.Errorf("证据图片必须由当前账号上传") + } + items = append(items, mediaURL) + } + return items, nil +} + +func (a *App) resolveMembershipEntitlements(ctx context.Context, userID int64) membershipEntitlements { + result := membershipEntitlements{DailyActiveChatLimit: a.resolveDailyActiveChatLimit(ctx, a.db, userID), DailyLikeLimit: 20} + _ = a.db.QueryRowContext(ctx, `SELECT CAST(config_value AS UNSIGNED) FROM system_configs WHERE config_key='membership.free_daily_like_limit'`).Scan(&result.DailyLikeLimit) + var visitors, invisible int + _ = a.db.QueryRowContext(ctx, `SELECT p.daily_like_limit,p.can_view_visitors,p.can_invisible_visit,p.recommendation_weight + FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id + WHERE s.user_id=? AND s.status=1 AND s.started_at<=NOW(3) AND s.expires_at>NOW(3) AND p.deleted_at IS NULL + ORDER BY p.level DESC,s.expires_at DESC LIMIT 1`, userID).Scan(&result.DailyLikeLimit, &visitors, &invisible, &result.RecommendationWeight) + result.CanViewVisitors = visitors == 1 + result.CanInvisibleVisit = invisible == 1 + return result +} + +type dailyLikeLimitError struct{ Limit int } + +func (e *dailyLikeLimitError) Error() string { + return fmt.Sprintf("今日点赞次数已达上限(%d次)", e.Limit) +} + +func (a *App) reserveDailyLike(tx *sql.Tx, r *http.Request, targetType string, targetID int64) error { + userID := current(r).ID + if _, err := tx.ExecContext(r.Context(), `INSERT IGNORE INTO user_daily_like_usage(user_id,usage_date,used_count) VALUES(?,CURRENT_DATE(),0)`, userID); err != nil { + return err + } + var used int + if err := tx.QueryRowContext(r.Context(), `SELECT used_count FROM user_daily_like_usage WHERE user_id=? AND usage_date=CURRENT_DATE() FOR UPDATE`, userID).Scan(&used); err != nil { + return err + } + var counted int + if err := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM user_daily_like_targets WHERE user_id=? AND target_type=? AND target_id=? AND usage_date=CURRENT_DATE())`, userID, targetType, targetID).Scan(&counted); err != nil { + return err + } + if counted == 1 { + return nil + } + entitlements := a.resolveMembershipEntitlements(r.Context(), userID) + if entitlements.DailyLikeLimit > 0 && used >= entitlements.DailyLikeLimit { + return &dailyLikeLimitError{Limit: entitlements.DailyLikeLimit} + } + if _, err := tx.ExecContext(r.Context(), `INSERT INTO user_daily_like_targets(user_id,target_type,target_id,usage_date) VALUES(?,?,?,CURRENT_DATE())`, userID, targetType, targetID); err != nil { + return err + } + _, err := tx.ExecContext(r.Context(), `UPDATE user_daily_like_usage SET used_count=used_count+1 WHERE user_id=? AND usage_date=CURRENT_DATE()`, userID) + return err +} + +func pageOptions(r *http.Request) (int, int, int) { + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + pageSize, _ := strconv.Atoi(r.URL.Query().Get("pageSize")) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 50 { + pageSize = 50 + } + return page, pageSize, (page - 1) * pageSize +} + +func nullableInt64(value sql.NullInt64) any { + if value.Valid { + return value.Int64 + } + return nil +} + +func (a *App) availableTags(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,category,name,icon,sort_order FROM tags WHERE status=1 ORDER BY category,sort_order,id`) + if err != nil { + fail(w, 500, 50001, "查询标签失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var category, name, icon string + var order int + if rows.Scan(&id, &category, &name, &icon, &order) == nil { + items = append(items, map[string]any{"id": id, "category": category, "name": name, "icon": icon, "sortOrder": order}) + } + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) myDevices(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT s.id,s.device_id,COALESCE(d.platform,''),COALESCE(d.device_model,''),COALESCE(d.os_version,''),COALESCE(d.app_version,''),COALESCE(d.last_ip,''),s.last_active_at,s.expires_at,s.created_at + FROM user_sessions s LEFT JOIN user_devices d ON d.user_id=s.user_id AND d.device_id=s.device_id + WHERE s.user_id=? AND s.revoked_at IS NULL AND s.expires_at>NOW(3) ORDER BY s.last_active_at DESC`, current(r).ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询登录设备失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var deviceID, platform, model, osVersion, appVersion, ip string + var active, expires, created time.Time + if rows.Scan(&id, &deviceID, &platform, &model, &osVersion, &appVersion, &ip, &active, &expires, &created) == nil { + items = append(items, map[string]any{"id": id, "deviceId": deviceID, "platform": platform, "deviceModel": model, "osVersion": osVersion, "appVersion": appVersion, "lastIp": ip, "lastActiveAt": active, "expiresAt": expires, "createdAt": created}) + } + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) revokeDevice(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "设备会话编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE id=? AND user_id=? AND revoked_at IS NULL`, id, current(r).ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "移除设备失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, http.StatusNotFound, 30001, "设备会话不存在") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) changeUserPassword(w http.ResponseWriter, r *http.Request) { + var req struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` + } + if decode(r, &req) != nil || req.CurrentPassword == "" || !validUserPassword(req.NewPassword) { + fail(w, http.StatusBadRequest, 20001, "请输入当前密码和新密码") + return + } + var oldHash string + if a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=? AND deleted_at IS NULL`, current(r).ID).Scan(&oldHash) != nil || !checkPassword(oldHash, req.CurrentPassword) { + fail(w, http.StatusUnauthorized, 10001, "当前密码错误") + return + } + newHash, err := hashPassword(req.NewPassword) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "密码加密失败") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "修改密码失败") + return + } + defer func() { _ = tx.Rollback() }() + _, err = tx.ExecContext(r.Context(), `UPDATE users SET password_hash=? WHERE id=?`, newHash, current(r).ID) + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, current(r).ID) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT INTO user_security_controls(user_id,token_version,force_logout_at,password_reset_at) VALUES(?,1,NOW(3),NOW(3)) ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=NOW(3),password_reset_at=NOW(3)`, current(r).ID) + } + if err != nil || tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "修改密码失败") + return + } + a.hub.disconnect(current(r).ID) + reply(w, map[string]bool{"success": true, "reloginRequired": true}) +} + +func (a *App) changeUserPhone(w http.ResponseWriter, r *http.Request) { + var req struct { + Phone string `json:"phone"` + Code string `json:"code"` + CurrentPassword string `json:"currentPassword"` + } + if decode(r, &req) != nil || !validPhone(req.Phone) || len(req.Code) != 6 || req.CurrentPassword == "" { + fail(w, http.StatusBadRequest, 20001, "当前密码、手机号或验证码格式错误") + return + } + var passwordHash string + if a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=? AND deleted_at IS NULL`, current(r).ID).Scan(&passwordHash) != nil || !checkPassword(passwordHash, req.CurrentPassword) { + fail(w, http.StatusUnauthorized, 10001, "当前密码错误") + return + } + if !a.consumeSMSCode(r, req.Phone, "change_phone", req.Code) { + fail(w, http.StatusBadRequest, 20001, "验证码错误或已过期") + return + } + var exists int + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM users WHERE phone_hash=? AND id<>? AND deleted_at IS NULL)`, phoneHash(req.Phone), current(r).ID).Scan(&exists) + if exists == 1 { + fail(w, http.StatusConflict, 20001, "手机号已被其他账号使用") + return + } + cipher, err := a.encryptPhone(req.Phone) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "加密手机号失败") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE users SET phone_hash=?,phone_cipher=? WHERE id=?`, phoneHash(req.Phone), cipher, current(r).ID) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, current(r).ID) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT INTO user_security_controls(user_id,token_version,force_logout_at) VALUES(?,1,NOW(3)) ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=NOW(3)`, current(r).ID) + } + if err != nil || tx.Commit() != nil { + _ = tx.Rollback() + fail(w, http.StatusInternalServerError, 50001, "更换手机号失败") + return + } + a.hub.disconnect(current(r).ID) + reply(w, map[string]bool{"success": true, "reloginRequired": true}) +} + +func (a *App) blockedUsers(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT blocked_user_id FROM user_blocks WHERE user_id=? ORDER BY created_at DESC`, current(r).ID) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询黑名单失败") + return + } + defer rows.Close() + items := []profileView{} + for rows.Next() { + var id int64 + _ = rows.Scan(&id) + if item, loadErr := a.loadProfile(r, id, current(r).ID); loadErr == nil { + items = append(items, item) + } + } + reply(w, map[string]any{"items": items}) +} + +type notificationSettingsView struct { + IMEnabled bool `json:"imEnabled"` + InteractionEnabled bool `json:"interactionEnabled"` + SystemEnabled bool `json:"systemEnabled"` + SoundEnabled bool `json:"soundEnabled"` + VibrationEnabled bool `json:"vibrationEnabled"` + QuietStart string `json:"quietStart"` + QuietEnd string `json:"quietEnd"` +} + +func (a *App) notificationAllowed(ctx context.Context, userID int64, notificationType string) bool { + var enabled int + column := "interaction_enabled" + if notificationType == "system" { + column = "system_enabled" + } else if notificationType == "im" { + column = "im_enabled" + } + err := a.db.QueryRowContext(ctx, `SELECT `+column+` FROM user_notification_settings WHERE user_id=?`, userID).Scan(&enabled) + return err == sql.ErrNoRows || (err == nil && enabled == 1) +} + +func (a *App) notifyUser(ctx context.Context, userID int64, notificationType, title, content, bizType string, bizID any) { + if userID <= 0 || !a.notificationAllowed(ctx, userID, notificationType) { + return + } + _, _ = a.db.ExecContext(ctx, `INSERT INTO notifications(user_id,type,title,content,biz_type,biz_id) VALUES(?,?,?,?,?,?)`, userID, notificationType, title, content, bizType, bizID) +} + +func (a *App) notificationSettings(w http.ResponseWriter, r *http.Request) { + _, _ = a.db.ExecContext(r.Context(), `INSERT IGNORE INTO user_notification_settings(user_id) VALUES(?)`, current(r).ID) + var item notificationSettingsView + if err := a.db.QueryRowContext(r.Context(), `SELECT im_enabled,interaction_enabled,system_enabled,sound_enabled,vibration_enabled,quiet_start,quiet_end FROM user_notification_settings WHERE user_id=?`, current(r).ID).Scan(&item.IMEnabled, &item.InteractionEnabled, &item.SystemEnabled, &item.SoundEnabled, &item.VibrationEnabled, &item.QuietStart, &item.QuietEnd); err != nil { + fail(w, http.StatusInternalServerError, 50001, "查询通知设置失败") + return + } + reply(w, item) +} + +func validClock(value string) bool { + if value == "" { + return true + } + if len(value) != 5 || value[2] != ':' { + return false + } + hour, hErr := strconv.Atoi(value[:2]) + minute, mErr := strconv.Atoi(value[3:]) + return hErr == nil && mErr == nil && hour >= 0 && hour < 24 && minute >= 0 && minute < 60 +} + +func (a *App) updateNotificationSettings(w http.ResponseWriter, r *http.Request) { + var req notificationSettingsView + if decode(r, &req) != nil || !validClock(req.QuietStart) || !validClock(req.QuietEnd) { + fail(w, http.StatusBadRequest, 20001, "通知设置格式错误") + return + } + _, err := a.db.ExecContext(r.Context(), `INSERT INTO user_notification_settings(user_id,im_enabled,interaction_enabled,system_enabled,sound_enabled,vibration_enabled,quiet_start,quiet_end) VALUES(?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE im_enabled=VALUES(im_enabled),interaction_enabled=VALUES(interaction_enabled),system_enabled=VALUES(system_enabled),sound_enabled=VALUES(sound_enabled),vibration_enabled=VALUES(vibration_enabled),quiet_start=VALUES(quiet_start),quiet_end=VALUES(quiet_end)`, current(r).ID, req.IMEnabled, req.InteractionEnabled, req.SystemEnabled, req.SoundEnabled, req.VibrationEnabled, req.QuietStart, req.QuietEnd) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存通知设置失败") + return + } + reply(w, req) +} + +func (a *App) registerPushToken(w http.ResponseWriter, r *http.Request) { + var req struct { + DeviceID string `json:"deviceId"` + Provider string `json:"provider"` + PushToken string `json:"pushToken"` + Platform string `json:"platform"` + AppVersion string `json:"appVersion"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.DeviceID) == "" || strings.TrimSpace(req.Provider) == "" || strings.TrimSpace(req.PushToken) == "" || len(req.PushToken) > 500 { + fail(w, http.StatusBadRequest, 20001, "推送设备信息不完整") + return + } + _, err := a.db.ExecContext(r.Context(), `INSERT INTO user_push_tokens(user_id,device_id,provider,push_token,platform,app_version,status,last_active_at) VALUES(?,?,?,?,?,?,1,NOW(3)) ON DUPLICATE KEY UPDATE push_token=VALUES(push_token),platform=VALUES(platform),app_version=VALUES(app_version),status=1,last_active_at=NOW(3)`, current(r).ID, req.DeviceID, req.Provider, req.PushToken, req.Platform, req.AppVersion) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存推送设备失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) deletePushToken(w http.ResponseWriter, r *http.Request) { + deviceID := strings.TrimSpace(r.URL.Query().Get("deviceId")) + if deviceID == "" { + fail(w, http.StatusBadRequest, 20001, "设备标识不能为空") + return + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE user_push_tokens SET status=0 WHERE user_id=? AND device_id=?`, current(r).ID, deviceID) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) feedback(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,category,content,contact,evidence_json,status,reply_content,created_at,updated_at FROM user_feedback WHERE user_id=? ORDER BY created_at DESC LIMIT 100`, current(r).ID) + if err != nil { + fail(w, 500, 50001, "查询反馈失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var category, content, contact, evidenceJSON, status, reply string + var created, updated time.Time + if rows.Scan(&id, &category, &content, &contact, &evidenceJSON, &status, &reply, &created, &updated) == nil { + var evidence []string + _ = json.Unmarshal([]byte(evidenceJSON), &evidence) + items = append(items, map[string]any{"id": id, "category": category, "content": content, "contact": contact, "evidence": evidence, "status": status, "reply": reply, "createdAt": created, "updatedAt": updated}) + } + } + reply(w, map[string]any{"items": items}) + return + } + var req struct { + Category string `json:"category"` + Content string `json:"content"` + Contact string `json:"contact"` + Evidence []string `json:"evidence"` + } + req.Category = strings.ToLower(strings.TrimSpace(req.Category)) + if decode(r, &req) != nil { + fail(w, 400, 20001, "反馈格式错误") + return + } + req.Category = strings.ToLower(strings.TrimSpace(req.Category)) + req.Content = strings.TrimSpace(req.Content) + allowed := map[string]bool{"bug": true, "suggestion": true, "complaint": true, "other": true} + if !allowed[req.Category] || len([]rune(req.Content)) < 5 || len([]rune(req.Content)) > 2000 || len(req.Evidence) > 6 { + fail(w, http.StatusBadRequest, 20001, "请选择反馈类型并填写 5-2000 字内容") + return + } + evidence, evidenceErr := a.validateOwnedImageEvidence(r.Context(), current(r).ID, req.Evidence) + if evidenceErr != nil { + fail(w, http.StatusBadRequest, 20001, evidenceErr.Error()) + return + } + evidenceJSON, _ := json.Marshal(evidence) + result, err := a.db.ExecContext(r.Context(), `INSERT INTO user_feedback(user_id,category,content,contact,evidence_json) VALUES(?,?,?,?,?)`, current(r).ID, req.Category, req.Content, strings.TrimSpace(req.Contact), evidenceJSON) + if err != nil { + fail(w, 500, 50001, "提交反馈失败") + return + } + id, _ := result.LastInsertId() + reply(w, map[string]any{"id": id, "status": "PENDING"}) +} + +func (a *App) accountClosure(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + var status, reason string + var requested, executeAfter time.Time + var cancelled, completed sql.NullTime + err := a.db.QueryRowContext(r.Context(), `SELECT status,reason,requested_at,execute_after,cancelled_at,completed_at FROM user_account_closures WHERE user_id=?`, current(r).ID).Scan(&status, &reason, &requested, &executeAfter, &cancelled, &completed) + if err == sql.ErrNoRows { + reply(w, map[string]any{"status": "NONE"}) + return + } + if err != nil { + fail(w, 500, 50001, "查询注销状态失败") + return + } + reply(w, map[string]any{"status": status, "reason": reason, "requestedAt": requested, "executeAfter": executeAfter, "cancelledAt": nullableTime(cancelled), "completedAt": nullableTime(completed)}) + return + } + if r.Method == http.MethodDelete { + result, err := a.db.ExecContext(r.Context(), `UPDATE user_account_closures SET status='CANCELLED',cancelled_at=NOW(3) WHERE user_id=? AND status='PENDING' AND execute_after>NOW(3)`, current(r).ID) + if err != nil { + fail(w, 500, 50001, "取消注销失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 409, 20001, "当前没有可取消的注销申请") + return + } + reply(w, map[string]bool{"success": true}) + return + } + var req struct { + Password string `json:"password"` + Reason string `json:"reason"` + } + if decode(r, &req) != nil || req.Password == "" { + fail(w, 400, 20001, "请输入当前密码确认注销") + return + } + var passwordHash string + if a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, current(r).ID).Scan(&passwordHash) != nil || !checkPassword(passwordHash, req.Password) { + fail(w, 401, 10001, "当前密码错误") + return + } + days, _ := strconv.Atoi(a.configPlain(r.Context(), "account.cancellation_cooling_days", "7")) + if days < 1 { + days = 7 + } + _, err := a.db.ExecContext(r.Context(), `INSERT INTO user_account_closures(user_id,reason,status,requested_at,execute_after,cancelled_at,completed_at) VALUES(?,?,'PENDING',NOW(3),DATE_ADD(NOW(3),INTERVAL ? DAY),NULL,NULL) ON DUPLICATE KEY UPDATE reason=VALUES(reason),status='PENDING',requested_at=NOW(3),execute_after=VALUES(execute_after),cancelled_at=NULL,completed_at=NULL`, current(r).ID, strings.TrimSpace(req.Reason), days) + if err != nil { + fail(w, 500, 50001, "提交注销申请失败") + return + } + reply(w, map[string]any{"status": "PENDING", "coolingDays": days}) +} + +func (a *App) executeDueAccountClosureContext(ctx context.Context, userID int64) bool { + tx, err := a.db.BeginTx(ctx, nil) + if err != nil { + return false + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(ctx, `UPDATE user_account_closures SET status='COMPLETED',completed_at=NOW(3) WHERE user_id=? AND status='PENDING' AND execute_after<=NOW(3)`, userID) + if err != nil { + return false + } + affected, _ := result.RowsAffected() + if affected == 0 { + return false + } + _, err = tx.ExecContext(ctx, `UPDATE users SET status=0,deleted_at=NOW(3),phone_hash=NULL,phone_cipher=NULL,password_hash='' WHERE id=?`, userID) + if err == nil { + _, err = tx.ExecContext(ctx, `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `UPDATE user_profiles SET nickname='已注销用户',avatar_url='',cover_url='',gender=0,birthday=NULL,height_cm=NULL,city_code='',city_name='',occupation='',education=0,relationship_status=0,bio='',is_vip=0,vip_level=0,last_active_at=NULL WHERE user_id=?`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `UPDATE posts SET status=0,deleted_at=COALESCE(deleted_at,NOW(3)) WHERE user_id=?`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `DELETE FROM user_location_states WHERE user_id=?`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `DELETE FROM user_push_tokens WHERE user_id=?`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `DELETE FROM user_oauth_identities WHERE user_id=?`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `DELETE FROM user_verifications WHERE user_id=?`, userID) + } + if err == nil { + _, err = tx.ExecContext(ctx, `UPDATE user_feedback SET contact='',evidence_json=JSON_ARRAY() WHERE user_id=?`, userID) + } + if err != nil || tx.Commit() != nil { + return false + } + a.hub.disconnect(userID) + return true +} + +func (a *App) executeDueAccountClosure(r *http.Request, userID int64) bool { + return a.executeDueAccountClosureContext(r.Context(), userID) +} + +func (a *App) processDueAccountClosures(ctx context.Context) { + rows, err := a.db.QueryContext(ctx, `SELECT user_id FROM user_account_closures WHERE status='PENDING' AND execute_after<=NOW(3) ORDER BY execute_after LIMIT 100`) + if err != nil { + return + } + ids := []int64{} + for rows.Next() { + var id int64 + if rows.Scan(&id) == nil { + ids = append(ids, id) + } + } + _ = rows.Close() + for _, id := range ids { + a.executeDueAccountClosureContext(ctx, id) + } +} + +func (a *App) exportMyData(w http.ResponseWriter, r *http.Request) { + profile, err := a.loadProfile(r, current(r).ID, current(r).ID) + if err != nil { + fail(w, 404, 30001, "用户不存在") + return + } + var privacy privacyView + var p [8]int + _ = a.db.QueryRowContext(r.Context(), `SELECT nearby_visible,distance_visible,online_visible,last_active_visible,allow_stranger_message,allow_profile_visit_record,allow_search,invisible_visit FROM user_privacy_settings WHERE user_id=?`, current(r).ID).Scan(&p[0], &p[1], &p[2], &p[3], &p[4], &p[5], &p[6], &p[7]) + privacy = privacyView{NearbyVisible: p[0] == 1, DistanceVisible: p[1] == 1, OnlineVisible: p[2] == 1, LastActiveVisible: p[3] == 1, AllowStrangerMessage: p[4] == 1, AllowProfileVisitRecord: p[5] == 1, AllowSearch: p[6] == 1, InvisibleVisit: p[7] == 1} + var posts, messages, orders int + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM posts WHERE user_id=? AND deleted_at IS NULL`, current(r).ID).Scan(&posts) + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM im_messages WHERE sender_id=?`, current(r).ID).Scan(&messages) + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM orders WHERE user_id=? AND deleted_at IS NULL`, current(r).ID).Scan(&orders) + reply(w, map[string]any{"exportedAt": time.Now(), "profile": profile, "privacy": privacy, "statistics": map[string]int{"posts": posts, "messages": messages, "orders": orders}, "notice": "聊天内容、订单明细和认证材料涉及敏感信息,请通过客服完成加密归档导出。"}) +} + +func (a *App) recordConsent(w http.ResponseWriter, r *http.Request) { + var req struct { + Type string `json:"type"` + Version string `json:"version"` + DeviceID string `json:"deviceId"` + } + if decode(r, &req) != nil || (req.Type != "user_agreement" && req.Type != "privacy_policy") || strings.TrimSpace(req.Version) == "" { + fail(w, 400, 20001, "协议确认信息无效") + return + } + _, err := a.db.ExecContext(r.Context(), `INSERT IGNORE INTO user_consents(user_id,agreement_type,agreement_version,ip,device_id) VALUES(?,?,?,?,?)`, current(r).ID, req.Type, strings.TrimSpace(req.Version), clientIP(r), strings.TrimSpace(req.DeviceID)) + if err != nil { + fail(w, 500, 50001, "保存协议确认失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) updatePost(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态编号无效") + return + } + var req struct { + Content string `json:"content"` + Visibility int `json:"visibility"` + Location string `json:"location"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.Content) == "" || len([]rune(req.Content)) > 2000 { + fail(w, 400, 20001, "动态内容需为 1-2000 字") + return + } + if req.Visibility != 2 { + req.Visibility = 1 + } + result, err := a.db.ExecContext(r.Context(), `UPDATE posts SET content=?,visibility=?,location_text=?,moderation_status=1 WHERE id=? AND user_id=? AND status=1`, strings.TrimSpace(req.Content), req.Visibility, strings.TrimSpace(req.Location), id, current(r).ID) + if err != nil { + fail(w, 500, 50001, "修改动态失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 404, 30001, "动态不存在或无权修改") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) deleteOwnPost(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE posts SET status=0,deleted_at=NOW(3) WHERE id=? AND user_id=? AND status=1`, id, current(r).ID) + if err != nil { + fail(w, 500, 50001, "删除动态失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 404, 30001, "动态不存在或无权删除") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) deleteOwnComment(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "评论编号无效") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "删除评论失败") + return + } + defer func() { _ = tx.Rollback() }() + var postID int64 + if err = tx.QueryRowContext(r.Context(), `SELECT c.post_id FROM post_comments c JOIN posts p ON p.id=c.post_id WHERE c.id=? AND (c.user_id=? OR p.user_id=?) AND c.status=1 FOR UPDATE`, id, current(r).ID, current(r).ID).Scan(&postID); err != nil { + fail(w, 404, 30001, "评论不存在或无权删除") + return + } + _, err = tx.ExecContext(r.Context(), `UPDATE post_comments SET status=0,deleted_at=NOW(3) WHERE id=?`, id) + if err == nil { + _, err = tx.ExecContext(r.Context(), `UPDATE posts SET comment_count=GREATEST(comment_count-1,0) WHERE id=?`, postID) + } + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "删除评论失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) readNotification(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "通知编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE notifications SET read_at=COALESCE(read_at,NOW(3)) WHERE id=? AND user_id=?`, id, current(r).ID) + if err != nil { + fail(w, 500, 50001, "更新通知失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 404, 30001, "通知不存在") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) myReports(w http.ResponseWriter, r *http.Request) { + page, pageSize, offset := pageOptions(r) + var total int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM reports WHERE reporter_user_id=?`, current(r).ID).Scan(&total); err != nil { + fail(w, 500, 50001, "查询举报记录失败") + return + } + rows, err := a.db.QueryContext(r.Context(), `SELECT id,target_type,target_id,reason_code,description,evidence_json,status,action_type,handle_remark,created_at,handled_at FROM reports WHERE reporter_user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?`, current(r).ID, pageSize, offset) + if err != nil { + fail(w, 500, 50001, "查询举报记录失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, targetID int64 + var typ, reason, description, evidenceJSON, status, actionType, handleRemark string + var created time.Time + var handled sql.NullTime + if rows.Scan(&id, &typ, &targetID, &reason, &description, &evidenceJSON, &status, &actionType, &handleRemark, &created, &handled) == nil { + var evidence []string + _ = json.Unmarshal([]byte(evidenceJSON), &evidence) + items = append(items, map[string]any{"id": id, "targetType": typ, "targetId": targetID, "reason": reason, "description": description, "evidence": evidence, "status": status, "actionType": actionType, "handleRemark": handleRemark, "createdAt": created, "handledAt": nullableTime(handled)}) + } + } + reply(w, map[string]any{"items": items, "page": page, "pageSize": pageSize, "total": total, "hasMore": offset+len(items) < total}) +} + +func (a *App) closeOwnOrder(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "订单编号无效") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE orders SET status='CLOSED' WHERE id=? AND user_id=? AND status='CREATED' AND deleted_at IS NULL`, id, current(r).ID) + if err != nil { + fail(w, 500, 50001, "关闭订单失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 409, 20001, "当前订单状态无法关闭") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) requestOrderRefund(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "订单编号无效") + return + } + var req struct { + Reason string `json:"reason"` + } + if decode(r, &req) != nil || len([]rune(strings.TrimSpace(req.Reason))) < 2 { + fail(w, 400, 20001, "请填写退款原因") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE orders SET status='REFUND_REQUESTED',refund_reason=?,refund_requested_at=NOW(3) WHERE id=? AND user_id=? AND status='PAID' AND deleted_at IS NULL`, strings.TrimSpace(req.Reason), id, current(r).ID) + if err != nil { + fail(w, 500, 50001, "提交退款申请失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 409, 20001, "当前订单状态无法申请退款") + return + } + reply(w, map[string]bool{"success": true}) +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/client_productization_test.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/client_productization_test.go new file mode 100644 index 0000000..50a640a --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/client_productization_test.go @@ -0,0 +1,35 @@ +package app + +import "testing" + +func TestValidClock(t *testing.T) { + tests := map[string]bool{ + "": true, + "00:00": true, + "09:30": true, + "23:59": true, + "24:00": false, + "12:60": false, + "9:30": false, + "noon": false, + } + for value, expected := range tests { + if actual := validClock(value); actual != expected { + t.Fatalf("validClock(%q) = %v, want %v", value, actual, expected) + } + } +} + +func TestOrderEntitlementActive(t *testing.T) { + active := []string{"PAID", "REFUND_REQUESTED", "REFUNDING"} + for _, status := range active { + if !orderEntitlementActive(status) { + t.Fatalf("status %s must keep the membership entitlement active", status) + } + } + for _, status := range []string{"CREATED", "REFUNDED", "CLOSED", "UNKNOWN"} { + if orderEntitlementActive(status) { + t.Fatalf("status %s must not grant a membership entitlement", status) + } + } +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/extensions.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/extensions.go new file mode 100644 index 0000000..15e3642 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/extensions.go @@ -0,0 +1,244 @@ +package app + +import ( + "database/sql" + "net/http" + "strings" +) + +func (a *App) searchUsers(w http.ResponseWriter, r *http.Request) { + keyword := strings.TrimSpace(r.URL.Query().Get("q")) + if keyword == "" { + reply(w, map[string]any{"items": []profileView{}, "total": 0}) + return + } + pattern := "%" + keyword + "%" + page, pageSize, offset := pageOptions(r) + var total int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users u JOIN user_profiles p ON p.user_id=u.id JOIN user_privacy_settings privacy ON privacy.user_id=u.id WHERE u.status=1 AND u.id<>? AND privacy.allow_search=1 AND (p.nickname LIKE ? OR u.public_id LIKE ?) AND NOT EXISTS(SELECT 1 FROM user_blocks b WHERE (b.user_id=? AND b.blocked_user_id=u.id) OR (b.user_id=u.id AND b.blocked_user_id=?))`, current(r).ID, pattern, pattern, current(r).ID, current(r).ID).Scan(&total); err != nil { + fail(w, 500, 50001, "搜索失败") + return + } + rows, err := a.db.QueryContext(r.Context(), `SELECT u.id FROM users u JOIN user_profiles p ON p.user_id=u.id JOIN user_privacy_settings privacy ON privacy.user_id=u.id WHERE u.status=1 AND u.id<>? AND privacy.allow_search=1 AND (p.nickname LIKE ? OR u.public_id LIKE ?) AND NOT EXISTS(SELECT 1 FROM user_blocks b WHERE (b.user_id=? AND b.blocked_user_id=u.id) OR (b.user_id=u.id AND b.blocked_user_id=?)) ORDER BY p.is_vip DESC,p.last_active_at DESC LIMIT ? OFFSET ?`, current(r).ID, pattern, pattern, current(r).ID, current(r).ID, pageSize, offset) + if err != nil { + fail(w, 500, 50001, "搜索失败") + return + } + defer rows.Close() + items := []profileView{} + for rows.Next() { + var id int64 + _ = rows.Scan(&id) + if item, loadErr := a.loadProfile(r, id, current(r).ID); loadErr == nil { + items = append(items, item) + } + } + reply(w, map[string]any{"items": items, "total": total, "keyword": keyword, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) +} + +func (a *App) followingList(w http.ResponseWriter, r *http.Request) { + a.relationshipList(w, r, "following") +} + +func (a *App) followerList(w http.ResponseWriter, r *http.Request) { + a.relationshipList(w, r, "followers") +} + +func (a *App) visitorList(w http.ResponseWriter, r *http.Request) { + if !a.resolveMembershipEntitlements(r.Context(), current(r).ID).CanViewVisitors { + fail(w, http.StatusForbidden, 10006, "开通会员后可查看访客记录") + return + } + a.relationshipList(w, r, "visitors") +} + +func (a *App) relationshipList(w http.ResponseWriter, r *http.Request, listType string) { + var rows *sql.Rows + var err error + page, pageSize, offset := pageOptions(r) + var total int + switch listType { + case "following": + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_follows WHERE user_id=?`, current(r).ID).Scan(&total) + rows, err = a.db.QueryContext(r.Context(), `SELECT target_user_id FROM user_follows WHERE user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?`, current(r).ID, pageSize, offset) + case "followers": + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_follows WHERE target_user_id=?`, current(r).ID).Scan(&total) + rows, err = a.db.QueryContext(r.Context(), `SELECT user_id FROM user_follows WHERE target_user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?`, current(r).ID, pageSize, offset) + default: + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(DISTINCT viewer_user_id) FROM profile_visits WHERE target_user_id=?`, current(r).ID).Scan(&total) + rows, err = a.db.QueryContext(r.Context(), `SELECT viewer_user_id FROM profile_visits WHERE target_user_id=? GROUP BY viewer_user_id ORDER BY MAX(visited_at) DESC LIMIT ? OFFSET ?`, current(r).ID, pageSize, offset) + } + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []profileView{} + for rows.Next() { + var id int64 + _ = rows.Scan(&id) + if item, loadErr := a.loadProfile(r, id, current(r).ID); loadErr == nil { + items = append(items, item) + } + } + reply(w, map[string]any{"items": items, "total": total, "type": listType, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) +} + +type privacyView struct { + NearbyVisible bool `json:"nearbyVisible"` + DistanceVisible bool `json:"distanceVisible"` + OnlineVisible bool `json:"onlineVisible"` + LastActiveVisible bool `json:"lastActiveVisible"` + AllowStrangerMessage bool `json:"allowStrangerMessage"` + AllowProfileVisitRecord bool `json:"allowProfileVisitRecord"` + InvisibleVisit bool `json:"invisibleVisit"` + AllowSearch bool `json:"allowSearch"` +} + +func (a *App) getPrivacy(w http.ResponseWriter, r *http.Request) { + var values [8]int + err := a.db.QueryRowContext(r.Context(), `SELECT nearby_visible,distance_visible,online_visible,last_active_visible,allow_stranger_message,allow_profile_visit_record,invisible_visit,allow_search FROM user_privacy_settings WHERE user_id=?`, current(r).ID).Scan(&values[0], &values[1], &values[2], &values[3], &values[4], &values[5], &values[6], &values[7]) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + reply(w, privacyView{NearbyVisible: values[0] == 1, DistanceVisible: values[1] == 1, OnlineVisible: values[2] == 1, LastActiveVisible: values[3] == 1, AllowStrangerMessage: values[4] == 1, AllowProfileVisitRecord: values[5] == 1, InvisibleVisit: values[6] == 1, AllowSearch: values[7] == 1}) +} + +func (a *App) updatePrivacy(w http.ResponseWriter, r *http.Request) { + var req privacyView + if decode(r, &req) != nil { + fail(w, 400, 20001, "隐私设置格式错误") + return + } + if req.InvisibleVisit && !a.resolveMembershipEntitlements(r.Context(), current(r).ID).CanInvisibleVisit { + fail(w, http.StatusForbidden, 10006, "当前会员等级不支持隐身访问") + return + } + _, err := a.db.ExecContext(r.Context(), `UPDATE user_privacy_settings SET nearby_visible=?,distance_visible=?,online_visible=?,last_active_visible=?,allow_stranger_message=?,allow_profile_visit_record=?,invisible_visit=?,allow_search=? WHERE user_id=?`, req.NearbyVisible, req.DistanceVisible, req.OnlineVisible, req.LastActiveVisible, req.AllowStrangerMessage, req.AllowProfileVisitRecord, req.InvisibleVisit, req.AllowSearch, current(r).ID) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + reply(w, req) +} + +func (a *App) blockUser(w http.ResponseWriter, r *http.Request) { + target, err := pathID(r) + if err != nil || target == current(r).ID { + fail(w, 400, 20001, "无效用户") + return + } + var targetExists int + if err = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM users WHERE id=? AND status=1 AND deleted_at IS NULL)`, target).Scan(&targetExists); err != nil || targetExists != 1 { + fail(w, http.StatusNotFound, 30001, "用户不存在") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "拉黑失败") + return + } + defer tx.Rollback() + _, err = tx.ExecContext(r.Context(), `INSERT IGNORE INTO user_blocks(user_id,blocked_user_id,reason)VALUES(?,?,'user_action')`, current(r).ID, target) + if err == nil { + _, err = tx.ExecContext(r.Context(), `DELETE FROM user_follows WHERE (user_id=? AND target_user_id=?) OR (user_id=? AND target_user_id=?)`, current(r).ID, target, target, current(r).ID) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `DELETE FROM user_likes WHERE (user_id=? AND target_user_id=?) OR (user_id=? AND target_user_id=?)`, current(r).ID, target, target, current(r).ID) + } + if err == nil { + err = tx.Commit() + } + if err != nil { + fail(w, 500, 50001, "拉黑失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) unblockUser(w http.ResponseWriter, r *http.Request) { + target, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "无效用户") + return + } + _, err = a.db.ExecContext(r.Context(), `DELETE FROM user_blocks WHERE user_id=? AND blocked_user_id=?`, current(r).ID, target) + if err != nil { + fail(w, 500, 50001, "解除拉黑失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) loadPost(r *http.Request, id int64) (postView, error) { + var item postView + var userID int64 + var vip int + err := a.db.QueryRowContext(r.Context(), `SELECT p.id,p.user_id,p.content,p.location_text,p.like_count,p.comment_count,p.created_at,u.public_id,pr.nickname,pr.avatar_url,pr.gender,pr.is_vip,EXISTS(SELECT 1 FROM post_likes l WHERE l.post_id=p.id AND l.user_id=?) FROM posts p JOIN users u ON u.id=p.user_id JOIN user_profiles pr ON pr.user_id=p.user_id WHERE p.id=? AND p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id)))`, current(r).ID, id, current(r).ID, current(r).ID).Scan(&item.ID, &userID, &item.Content, &item.Location, &item.LikeCount, &item.CommentCount, &item.CreatedAt, &item.User.PublicID, &item.User.Nickname, &item.User.Avatar, &item.User.Gender, &vip, &item.Liked) + if err != nil { + return item, err + } + item.User.ID = userID + item.User.VIP = vip == 1 + item.CanEdit = userID == current(r).ID + item.CanDelete = userID == current(r).ID + item.Media = []string{} + rows, _ := a.db.QueryContext(r.Context(), `SELECT media_url FROM post_media WHERE post_id=? ORDER BY sort_order`, id) + if rows != nil { + defer rows.Close() + for rows.Next() { + var media string + _ = rows.Scan(&media) + item.Media = append(item.Media, media) + } + } + return item, nil +} + +func (a *App) postDetail(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态编号无效") + return + } + item, err := a.loadPost(r, id) + if err != nil { + fail(w, 404, 30001, "动态不存在") + return + } + reply(w, item) +} + +func (a *App) userPosts(w http.ResponseWriter, r *http.Request) { + userID, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "用户编号无效") + return + } + page, pageSize, offset := pageOptions(r) + var total int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM posts WHERE user_id=? AND status=1 AND (visibility=1 OR user_id=? OR (visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=posts.user_id)))`, userID, current(r).ID, current(r).ID).Scan(&total); err != nil { + fail(w, 500, 50001, "查询失败") + return + } + rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM posts WHERE user_id=? AND status=1 AND (visibility=1 OR user_id=? OR (visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=posts.user_id))) ORDER BY created_at DESC LIMIT ? OFFSET ?`, userID, current(r).ID, current(r).ID, pageSize, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + ids := []int64{} + for rows.Next() { + var id int64 + _ = rows.Scan(&id) + ids = append(ids, id) + } + items := []postView{} + for _, id := range ids { + if item, loadErr := a.loadPost(r, id); loadErr == nil { + items = append(items, item) + } + } + reply(w, map[string]any{"items": items, "total": total, "userId": userID, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/im.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/im.go new file mode 100644 index 0000000..841a956 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/im.go @@ -0,0 +1,755 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/gorilla/websocket" +) + +type messageView struct { + ID int64 `json:"id"` + ConversationID int64 `json:"conversationId"` + Seq int64 `json:"seq"` + SenderID int64 `json:"senderId"` + ClientMsgID string `json:"clientMsgId"` + Type int `json:"type"` + Content any `json:"content"` + Recalled bool `json:"recalled"` + CreatedAt time.Time `json:"createdAt"` +} + +const ( + maxMessageBodyBytes = 16 << 10 + maxMessageTextRunes = 2000 +) + +var clientMessageIDPattern = regexp.MustCompile(`^[A-Za-z0-9_.:-]{1,64}$`) + +func validMessageMediaURL(raw string, production bool) bool { + raw = strings.TrimSpace(raw) + if raw == "" || len(raw) > 2048 { + return false + } + if strings.HasPrefix(raw, "/uploads/") { + return !strings.Contains(raw, "..") + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return false + } + return !production || parsed.Scheme == "https" +} + +func numericDuration(value any) (int, bool) { + switch duration := value.(type) { + case float64: + return int(duration), duration == float64(int(duration)) + case int: + return duration, true + case int64: + return int(duration), true + case json.Number: + parsed, err := strconv.Atoi(duration.String()) + return parsed, err == nil + default: + return 0, false + } +} + +func (a *App) validateMessagePayload(clientMsgID string, messageType int, content any) (string, any, []byte, error) { + clientMsgID = strings.TrimSpace(clientMsgID) + if clientMsgID == "" { + clientMsgID = randomToken()[:26] + } + if !clientMessageIDPattern.MatchString(clientMsgID) { + return "", nil, nil, fmt.Errorf("客户端消息 ID 格式无效") + } + if messageType == 0 { + messageType = 1 + } + data, ok := content.(map[string]any) + if !ok { + return "", nil, nil, fmt.Errorf("消息内容格式错误") + } + switch messageType { + case 1: + text, ok := data["text"].(string) + text = strings.TrimSpace(text) + if !ok || text == "" || !utf8.ValidString(text) || utf8.RuneCountInString(text) > maxMessageTextRunes { + return "", nil, nil, fmt.Errorf("文本消息应为 1 至 %d 个字符", maxMessageTextRunes) + } + data = map[string]any{"text": text} + case 2: + mediaURL, ok := data["url"].(string) + if !ok || !validMessageMediaURL(mediaURL, a.config.Environment == "production") { + return "", nil, nil, fmt.Errorf("图片地址无效") + } + data = map[string]any{"url": strings.TrimSpace(mediaURL)} + case 3: + mediaURL, ok := data["url"].(string) + duration, durationOK := numericDuration(data["duration"]) + if !ok || !validMessageMediaURL(mediaURL, a.config.Environment == "production") || !durationOK || duration < 1 || duration > 60 { + return "", nil, nil, fmt.Errorf("语音消息地址或时长无效") + } + data = map[string]any{"duration": duration, "url": strings.TrimSpace(mediaURL)} + default: + return "", nil, nil, fmt.Errorf("不支持的消息类型") + } + body, err := json.Marshal(data) + if err != nil || len(body) > maxMessageBodyBytes { + return "", nil, nil, fmt.Errorf("消息内容过大") + } + return clientMsgID, data, body, nil +} + +type wsClient struct { + userID int64 + conn *websocket.Conn + mu sync.Mutex +} + +func (c *wsClient) writeJSON(payload any) error { + c.mu.Lock() + defer c.mu.Unlock() + _ = c.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + return c.conn.WriteJSON(payload) +} + +type Hub struct { + mu sync.RWMutex + clients map[int64]map[*wsClient]struct{} +} + +func NewHub() *Hub { return &Hub{clients: make(map[int64]map[*wsClient]struct{})} } + +func (h *Hub) add(client *wsClient) { + h.mu.Lock() + defer h.mu.Unlock() + if h.clients[client.userID] == nil { + h.clients[client.userID] = make(map[*wsClient]struct{}) + } + h.clients[client.userID][client] = struct{}{} +} + +func (h *Hub) remove(client *wsClient) { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.clients[client.userID], client) + if len(h.clients[client.userID]) == 0 { + delete(h.clients, client.userID) + } +} + +func (h *Hub) broadcast(userIDs []int64, payload any) { + h.mu.RLock() + targets := []*wsClient{} + for _, userID := range userIDs { + for client := range h.clients[userID] { + targets = append(targets, client) + } + } + h.mu.RUnlock() + for _, client := range targets { + _ = client.writeJSON(payload) + } +} + +func (h *Hub) disconnect(userID int64) { + h.mu.RLock() + targets := []*wsClient{} + for client := range h.clients[userID] { + targets = append(targets, client) + } + h.mu.RUnlock() + for _, client := range targets { + client.mu.Lock() + _ = client.conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "登录状态已失效"), time.Now().Add(time.Second)) + _ = client.conn.Close() + client.mu.Unlock() + } +} + +func (a *App) directConversation(w http.ResponseWriter, r *http.Request) { + var req struct { + UserID int64 `json:"userId"` + } + if decode(r, &req) != nil || req.UserID == 0 || req.UserID == current(r).ID { + fail(w, 400, 20001, "无效的聊天对象") + return + } + first, second := current(r).ID, req.UserID + if first > second { + first, second = second, first + } + var targetStatus, allowStranger int + if err := a.db.QueryRowContext(r.Context(), `SELECT u.status,p.allow_stranger_message FROM users u JOIN user_privacy_settings p ON p.user_id=u.id WHERE u.id=? AND u.deleted_at IS NULL`, req.UserID).Scan(&targetStatus, &allowStranger); err != nil || targetStatus != 1 { + fail(w, http.StatusNotFound, 30001, "聊天对象不存在或不可用") + return + } + var blocked int + if err := a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM user_blocks WHERE (user_id=? AND blocked_user_id=?) OR (user_id=? AND blocked_user_id=?))`, current(r).ID, req.UserID, req.UserID, current(r).ID).Scan(&blocked); err != nil || blocked == 1 { + fail(w, http.StatusForbidden, 30002, "当前无法与该用户聊天") + return + } + var id int64 + err := a.db.QueryRowContext(r.Context(), `SELECT conversation_id FROM im_direct_conversations WHERE user1_id=? AND user2_id=?`, first, second).Scan(&id) + if err == nil { + reply(w, map[string]any{"id": id}) + return + } + if allowStranger == 0 { + var mutualFollow int + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS( + SELECT 1 FROM user_follows mine JOIN user_follows target + ON target.user_id=mine.target_user_id AND target.target_user_id=mine.user_id + WHERE mine.user_id=? AND mine.target_user_id=?)`, current(r).ID, req.UserID).Scan(&mutualFollow) + if mutualFollow != 1 { + fail(w, http.StatusForbidden, 30002, "对方仅允许互相关注的人发起私信") + return + } + } + tx, beginErr := a.db.BeginTx(r.Context(), nil) + if beginErr != nil { + fail(w, http.StatusInternalServerError, 50001, "创建会话失败") + return + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(r.Context(), `INSERT INTO im_conversations(conversation_type)VALUES(1)`) + if err != nil { + _ = tx.Rollback() + // Concurrent requests may have created the same unique direct pair. + if a.db.QueryRowContext(r.Context(), `SELECT conversation_id FROM im_direct_conversations WHERE user1_id=? AND user2_id=?`, first, second).Scan(&id) == nil { + reply(w, map[string]any{"id": id}) + return + } + fail(w, 500, 50001, "创建会话失败") + return + } + id, _ = result.LastInsertId() + _, err = tx.ExecContext(r.Context(), `INSERT INTO im_direct_conversations(conversation_id,user1_id,user2_id)VALUES(?,?,?)`, id, first, second) + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT INTO im_conversation_members(conversation_id,user_id)VALUES(?,?),(?,?)`, id, first, id, second) + } + if err != nil { + _ = tx.Rollback() + fail(w, 500, 50001, "创建会话失败") + return + } + _ = tx.Commit() + reply(w, map[string]any{"id": id}) +} + +func (a *App) conversations(w http.ResponseWriter, r *http.Request) { + who := current(r) + // A sequence is shared by both senders, so last_seq-read_seq is not an unread count. + rows, err := a.db.QueryContext(r.Context(), `SELECT c.id,c.last_seq,c.last_message_at, + (SELECT COUNT(*) FROM im_messages unread_msg WHERE unread_msg.conversation_id=c.id + AND unread_msg.seq>GREATEST(m.read_seq,m.clear_seq,m.join_seq) AND unread_msg.sender_id<>m.user_id + AND unread_msg.recalled_at IS NULL AND unread_msg.admin_removed_at IS NULL),m.pinned,m.muted, + other.user_id,p.nickname,p.avatar_url,p.is_vip,p.last_active_at,privacy.online_visible,COALESCE(CAST(msg.body AS CHAR CHARACTER SET utf8mb4),''),msg.recalled_at,msg.admin_removed_at + FROM im_conversation_members m JOIN im_conversations c ON c.id=m.conversation_id + JOIN im_conversation_members other ON other.conversation_id=c.id AND other.user_id<>m.user_id + JOIN user_profiles p ON p.user_id=other.user_id JOIN user_privacy_settings privacy ON privacy.user_id=other.user_id LEFT JOIN im_messages msg ON msg.id=c.last_message_id + WHERE m.user_id=? AND m.status=1 AND c.status=1 ORDER BY m.pinned DESC,c.last_message_at DESC`, who.ID) + if err != nil { + fail(w, 500, 50001, err.Error()) + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, lastSeq, unread, otherID int64 + var lastAt sql.NullTime + var pinned, muted, vip int + var nick, avatar, body string + var active sql.NullTime + var recalledAt, adminRemovedAt sql.NullTime + var onlineVisible int + if err := rows.Scan(&id, &lastSeq, &lastAt, &unread, &pinned, &muted, &otherID, &nick, &avatar, &vip, &active, &onlineVisible, &body, &recalledAt, &adminRemovedAt); err != nil { + fail(w, 500, 50001, "读取会话失败") + return + } + preview := "开始聊天吧" + var content map[string]any + if recalledAt.Valid || adminRemovedAt.Valid { + preview = "消息已撤回" + } else if json.Unmarshal([]byte(body), &content) == nil { + if text, ok := content["text"].(string); ok { + preview = text + } + } + items = append(items, map[string]any{"id": id, "lastSeq": lastSeq, "unread": unread, "lastMessageAt": nullableTime(lastAt), "pinned": pinned == 1, "muted": muted == 1, "lastMessage": preview, "user": map[string]any{"id": otherID, "nickname": nick, "avatar": avatar, "vip": vip == 1, "online": onlineVisible == 1 && active.Valid && time.Since(active.Time) < 15*time.Minute}}) + } + if err := rows.Err(); err != nil { + fail(w, 500, 50001, "读取会话失败") + return + } + reply(w, map[string]any{"items": items, "total": len(items)}) +} + +func (a *App) messages(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "invalid conversation") + return + } + if !a.isMember(r, id, current(r).ID) { + fail(w, 403, 30002, "不是会话成员") + return + } + beforeSeq, _ := strconv.ParseInt(r.URL.Query().Get("beforeSeq"), 10, 64) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 || limit > 100 { + limit = 50 + } + query := `SELECT id,conversation_id,seq,sender_id,client_msg_id,message_type,body,recalled_at,admin_removed_at,created_at FROM im_messages WHERE conversation_id=?` + args := []any{id} + if beforeSeq > 0 { + query += ` AND seq limit + if hasMore { + items = items[:limit] + } + sort.Slice(items, func(i, j int) bool { return items[i].Seq < items[j].Seq }) + if len(items) > 0 { + last := items[len(items)-1].Seq + if err := a.markConversationRead(r.Context(), id, current(r).ID, last); err != nil { + fail(w, 500, 50001, "更新已读状态失败") + return + } + } + nextBeforeSeq := int64(0) + if len(items) > 0 { + nextBeforeSeq = items[0].Seq + } + reply(w, map[string]any{"items": items, "hasMore": hasMore, "nextBeforeSeq": nextBeforeSeq}) +} + +func (a *App) sendMessageHTTP(w http.ResponseWriter, r *http.Request) { + conversationID, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "invalid conversation") + return + } + var req struct { + ClientMsgID string `json:"clientMsgId"` + Type int `json:"type"` + Content any `json:"content"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "消息格式错误") + return + } + item, members, err := a.persistMessage(r, conversationID, current(r).ID, req.ClientMsgID, req.Type, req.Content) + if err != nil { + var limitErr *dailyActiveChatLimitError + if errors.As(err, &limitErr) { + fail(w, http.StatusTooManyRequests, 30005, limitErr.Error()) + return + } + fail(w, 400, 30004, err.Error()) + return + } + a.hub.broadcast(members, map[string]any{"command": "MESSAGE_PUSH", "data": item}) + reply(w, item) +} + +func (a *App) recallMessage(w http.ResponseWriter, r *http.Request) { + messageID, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "消息编号无效") + return + } + window, _ := strconv.Atoi(a.configPlain(r.Context(), "im.recall_seconds", "120")) + if window < 1 || window > 86400 { + window = 120 + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "撤回失败") + return + } + defer func() { _ = tx.Rollback() }() + var conversationID, seq int64 + var senderID int64 + var created time.Time + var recalled sql.NullTime + if err = tx.QueryRowContext(r.Context(), `SELECT conversation_id,seq,sender_id,created_at,recalled_at FROM im_messages WHERE id=? FOR UPDATE`, messageID).Scan(&conversationID, &seq, &senderID, &created, &recalled); err != nil { + fail(w, 404, 30001, "消息不存在") + return + } + if senderID != current(r).ID { + fail(w, 403, 30002, "只能撤回自己发送的消息") + return + } + if recalled.Valid { + reply(w, map[string]bool{"success": true}) + return + } + if time.Since(created) > time.Duration(window)*time.Second { + fail(w, 409, 30004, "已超过消息撤回时限") + return + } + _, err = tx.ExecContext(r.Context(), `UPDATE im_messages SET recalled_at=NOW(3) WHERE id=?`, messageID) + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "撤回失败") + return + } + rows, _ := a.db.QueryContext(r.Context(), `SELECT user_id FROM im_conversation_members WHERE conversation_id=? AND status=1`, conversationID) + members := []int64{} + if rows != nil { + defer rows.Close() + for rows.Next() { + var id int64 + _ = rows.Scan(&id) + members = append(members, id) + } + } + a.hub.broadcast(members, map[string]any{"command": "MESSAGE_RECALLED", "data": map[string]any{"id": messageID, "conversationId": conversationID, "seq": seq}}) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) persistMessage(r *http.Request, conversationID, senderID int64, clientMsgID string, messageType int, content any) (messageView, []int64, error) { + item := messageView{} + clientMsgID, content, body, err := a.validateMessagePayload(clientMsgID, messageType, content) + if err != nil { + return item, nil, err + } + if messageType == 0 { + messageType = 1 + } + if messageType == 2 || messageType == 3 { + contentMap, _ := content.(map[string]any) + mediaURL, _ := contentMap["url"].(string) + expectedType := "image" + if messageType == 3 { + expectedType = "audio" + } + var owned int + if queryErr := a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM media_assets WHERE owner_user_id=? AND public_url=? AND media_type=? AND status=1 AND moderation_status=1)`, senderID, mediaURL, expectedType).Scan(&owned); queryErr != nil || owned != 1 { + return item, nil, fmt.Errorf("消息媒体必须由当前账号上传") + } + } + if !a.allowRequest(r.Context(), "message_send", fmt.Sprintf("%d", senderID), 120, time.Minute) { + return item, nil, fmt.Errorf("消息发送过于频繁,请稍后再试") + } + if a.isSanctionActive(r.Context(), senderID, "MUTE") { + return item, nil, fmt.Errorf("账号处于禁言期,暂时无法发送消息") + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + return item, nil, err + } + defer func() { _ = tx.Rollback() }() + var lastSeq int64 + if err = tx.QueryRowContext(r.Context(), `SELECT last_seq FROM im_conversations WHERE id=? AND status=1 FOR UPDATE`, conversationID).Scan(&lastSeq); err != nil { + return item, nil, fmt.Errorf("会话不存在") + } + var memberCount int + if err = tx.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM im_conversation_members WHERE conversation_id=? AND user_id=? AND status=1`, conversationID, senderID).Scan(&memberCount); err != nil || memberCount == 0 { + return item, nil, fmt.Errorf("不是会话成员") + } + var blocked int + if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS( + SELECT 1 FROM im_direct_conversations d JOIN user_blocks b + ON (b.user_id=d.user1_id AND b.blocked_user_id=d.user2_id) OR (b.user_id=d.user2_id AND b.blocked_user_id=d.user1_id) + WHERE d.conversation_id=?)`, conversationID).Scan(&blocked); err != nil { + return item, nil, err + } + if blocked == 1 { + return item, nil, fmt.Errorf("当前无法向该用户发送消息") + } + if err = a.reserveDailyActiveChat(r.Context(), tx, conversationID, senderID); err != nil { + return item, nil, err + } + seq := lastSeq + 1 + result, err := tx.ExecContext(r.Context(), `INSERT INTO im_messages(conversation_id,seq,sender_id,client_msg_id,message_type,body)VALUES(?,?,?,?,?,?)`, conversationID, seq, senderID, clientMsgID, messageType, body) + if err != nil { + var existingID, existingSeq int64 + existingErr := tx.QueryRowContext(r.Context(), `SELECT id,seq FROM im_messages WHERE sender_id=? AND client_msg_id=?`, senderID, clientMsgID).Scan(&existingID, &existingSeq) + if existingErr == nil { + _ = tx.Rollback() + return a.loadMessage(r, existingID), nil, nil + } + return item, nil, err + } + messageID, _ := result.LastInsertId() + _, err = tx.ExecContext(r.Context(), `UPDATE im_conversations SET last_seq=?,last_message_id=?,last_message_at=NOW(3) WHERE id=?`, seq, messageID, conversationID) + if err != nil { + return item, nil, err + } + if _, err = tx.ExecContext(r.Context(), `UPDATE im_conversation_members SET delivered_seq=GREATEST(delivered_seq,?),updated_at=NOW(3) WHERE conversation_id=?`, seq, conversationID); err != nil { + return item, nil, err + } + memberRows, err := tx.QueryContext(r.Context(), `SELECT user_id FROM im_conversation_members WHERE conversation_id=? AND status=1`, conversationID) + if err != nil { + return item, nil, err + } + members := []int64{} + for memberRows.Next() { + var userID int64 + if err = memberRows.Scan(&userID); err != nil { + _ = memberRows.Close() + return item, nil, err + } + members = append(members, userID) + } + if err = memberRows.Err(); err != nil { + _ = memberRows.Close() + return item, nil, err + } + if err = memberRows.Close(); err != nil { + return item, nil, err + } + sort.Slice(members, func(i, j int) bool { return members[i] < members[j] }) + for _, userID := range members { + var lockedUserID int64 + if err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE id=? FOR UPDATE`, userID).Scan(&lockedUserID); err != nil { + return item, nil, err + } + } + for _, userID := range members { + var next int64 + if err = tx.QueryRowContext(r.Context(), `SELECT COALESCE(MAX(event_seq),0)+1 FROM im_user_sync_events WHERE user_id=?`, userID).Scan(&next); err != nil { + return item, nil, err + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO im_user_sync_events(user_id,event_seq,event_type,conversation_id,message_seq,event_data)VALUES(?, ?,12,?,?,?)`, userID, next, conversationID, seq, body); err != nil { + return item, nil, err + } + } + if err = tx.Commit(); err != nil { + return item, nil, err + } + return messageView{ID: messageID, ConversationID: conversationID, Seq: seq, SenderID: senderID, ClientMsgID: clientMsgID, Type: messageType, Content: content, CreatedAt: time.Now()}, members, nil +} + +func (a *App) loadMessage(r *http.Request, id int64) messageView { + var item messageView + var body []byte + var recalledAt, adminRemovedAt sql.NullTime + _ = a.db.QueryRowContext(r.Context(), `SELECT id,conversation_id,seq,sender_id,client_msg_id,message_type,body,recalled_at,admin_removed_at,created_at FROM im_messages WHERE id=?`, id).Scan(&item.ID, &item.ConversationID, &item.Seq, &item.SenderID, &item.ClientMsgID, &item.Type, &body, &recalledAt, &adminRemovedAt, &item.CreatedAt) + item.Recalled = recalledAt.Valid || adminRemovedAt.Valid + if !item.Recalled { + _ = json.Unmarshal(body, &item.Content) + } + return item +} +func (a *App) isMember(r *http.Request, conversationID, userID int64) bool { + var count int + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM im_conversation_members WHERE conversation_id=? AND user_id=? AND status=1`, conversationID, userID).Scan(&count) + return count > 0 +} + +func (a *App) conversationSettings(w http.ResponseWriter, r *http.Request) { + id, pathErr := pathID(r) + if pathErr != nil { + fail(w, http.StatusBadRequest, 20001, "会话 ID 无效") + return + } + var req struct { + Pinned *bool `json:"pinned"` + Muted *bool `json:"muted"` + ReadSeq int64 `json:"readSeq"` + } + if decode(r, &req) != nil || req.ReadSeq < 0 { + fail(w, 400, 20001, "invalid settings") + return + } + pinned, muted := -1, -1 + if req.Pinned != nil { + pinned = btoi(*req.Pinned) + } + if req.Muted != nil { + muted = btoi(*req.Muted) + } + var lastSeq int64 + if err := a.db.QueryRowContext(r.Context(), `SELECT c.last_seq FROM im_conversations c JOIN im_conversation_members m ON m.conversation_id=c.id WHERE c.id=? AND m.user_id=? AND m.status=1`, id, current(r).ID).Scan(&lastSeq); err != nil { + fail(w, http.StatusForbidden, 30002, "不是会话成员") + return + } + readSeq := req.ReadSeq + if readSeq > lastSeq { + readSeq = lastSeq + } + _, err := a.db.ExecContext(r.Context(), `UPDATE im_conversation_members SET pinned=IF(?>=0,?,pinned),muted=IF(?>=0,?,muted),read_seq=GREATEST(read_seq,?) WHERE conversation_id=? AND user_id=?`, pinned, pinned, muted, muted, readSeq, id, current(r).ID) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + if req.ReadSeq > 0 { + a.publishConversationRead(id, current(r).ID, readSeq) + } + reply(w, map[string]bool{"success": true}) +} + +// Notify this user's other devices after a successful read; never notify the peer +// or advance the sender's read marker merely because they sent a message. +func (a *App) publishConversationRead(conversationID, userID, readSeq int64) { + a.hub.broadcast([]int64{userID}, map[string]any{"command": "READ_ACK", "data": map[string]any{ + "conversationId": conversationID, "readSeq": readSeq, + }}) +} + +func (a *App) markConversationRead(ctx context.Context, conversationID, userID, readSeq int64) error { + if readSeq < 0 { + return fmt.Errorf("已读序号无效") + } + result, err := a.db.ExecContext(ctx, `UPDATE im_conversation_members m JOIN im_conversations c ON c.id=m.conversation_id + SET m.read_seq=GREATEST(m.read_seq,LEAST(?,c.last_seq)) + WHERE m.conversation_id=? AND m.user_id=? AND m.status=1`, readSeq, conversationID, userID) + if err != nil { + return err + } + if changed, _ := result.RowsAffected(); changed > 0 { + var saved int64 + if err := a.db.QueryRowContext(ctx, `SELECT read_seq FROM im_conversation_members WHERE conversation_id=? AND user_id=?`, conversationID, userID).Scan(&saved); err != nil { + return err + } + a.publishConversationRead(conversationID, userID, saved) + } + return nil +} + +func (a *App) websocket(w http.ResponseWriter, r *http.Request) { + if !a.originAllowed(r.Header.Get("Origin")) { + fail(w, http.StatusForbidden, 10006, "WebSocket 来源不允许") + return + } + selectedProtocol := "" + raw := "" + for _, protocol := range websocket.Subprotocols(r) { + if strings.HasPrefix(protocol, "xingyu.jwt.") { + selectedProtocol = protocol + raw = strings.TrimPrefix(protocol, "xingyu.jwt.") + break + } + } + // Query-token compatibility is development-only because URLs may be written to proxy logs. + if raw == "" && a.config.Environment != "production" { + raw = r.URL.Query().Get("token") + } + who, err := a.parseToken(raw) + if err != nil || who.Role != "user" { + fail(w, 401, 10001, "invalid token") + return + } + var userStatus int + statusErr := a.db.QueryRowContext(r.Context(), `SELECT status FROM users WHERE id=? AND deleted_at IS NULL`, who.ID).Scan(&userStatus) + if statusErr == nil { + userStatus = a.normalizeUserStatus(r.Context(), who.ID, userStatus) + } + if statusErr != nil || userStatus != 1 { + fail(w, 403, 10006, "账号已被冻结或封禁") + return + } + var tokenVersion int + _ = a.db.QueryRowContext(r.Context(), `SELECT token_version FROM user_security_controls WHERE user_id=?`, who.ID).Scan(&tokenVersion) + if who.Version != tokenVersion { + fail(w, 401, 10001, "登录状态已失效") + return + } + upgrader := websocket.Upgrader{CheckOrigin: func(request *http.Request) bool { + return a.originAllowed(request.Header.Get("Origin")) + }} + if selectedProtocol != "" { + upgrader.Subprotocols = []string{selectedProtocol} + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + client := &wsClient{userID: who.ID, conn: conn} + conn.SetReadLimit(64 << 10) + _ = conn.SetReadDeadline(time.Now().Add(75 * time.Second)) + a.hub.add(client) + defer func() { a.hub.remove(client); _ = conn.Close() }() + _ = client.writeJSON(map[string]any{"command": "AUTH_ACK", "data": map[string]any{"heartbeatSeconds": 25, "serverTime": time.Now().UnixMilli()}}) + for { + var frame struct { + Command string `json:"command"` + ConversationID int64 `json:"conversationId"` + ClientMsgID string `json:"clientMsgId"` + Type int `json:"type"` + Content any `json:"content"` + ReadSeq int64 `json:"readSeq"` + } + if conn.ReadJSON(&frame) != nil { + return + } + switch frame.Command { + case "PING": + _ = conn.SetReadDeadline(time.Now().Add(75 * time.Second)) + _ = client.writeJSON(map[string]any{"command": "PONG", "timestamp": time.Now().UnixMilli()}) + case "SEND_MESSAGE": + item, members, persistErr := a.persistMessage(r, frame.ConversationID, who.ID, frame.ClientMsgID, frame.Type, frame.Content) + if persistErr != nil { + _ = client.writeJSON(map[string]any{"command": "ERROR", "message": persistErr.Error()}) + continue + } + a.hub.broadcast(members, map[string]any{"command": "MESSAGE_PUSH", "data": item}) + case "READ": + if frame.ReadSeq < 0 { + _ = client.writeJSON(map[string]any{"command": "ERROR", "message": "已读序号无效"}) + continue + } + var lastSeq int64 + if a.db.QueryRowContext(r.Context(), `SELECT c.last_seq FROM im_conversations c JOIN im_conversation_members m ON m.conversation_id=c.id WHERE c.id=? AND m.user_id=? AND m.status=1`, frame.ConversationID, who.ID).Scan(&lastSeq) != nil { + _ = client.writeJSON(map[string]any{"command": "ERROR", "message": "不是会话成员"}) + continue + } + if frame.ReadSeq > lastSeq { + frame.ReadSeq = lastSeq + } + if err := a.markConversationRead(r.Context(), frame.ConversationID, who.ID, frame.ReadSeq); err != nil { + _ = client.writeJSON(map[string]any{"command": "ERROR", "message": "更新已读状态失败"}) + continue + } + _ = client.writeJSON(map[string]any{"command": "READ_ACK", "data": frame}) + } + } +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/im_unread_integration_test.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/im_unread_integration_test.go new file mode 100644 index 0000000..08095c8 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/im_unread_integration_test.go @@ -0,0 +1,269 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/go-sql-driver/mysql" + "github.com/gorilla/websocket" +) + +// Never run against IM or a remote database: create and clean up a unique local +// schema, apply the real migrations, and exercise the actual handlers/SQL. +func isolatedIMDatabase(t *testing.T) *sql.DB { + t.Helper() + dsn := os.Getenv("IM_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("set IM_TEST_MYSQL_DSN to enable isolated local IM integration tests") + } + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + t.Fatal("invalid test DSN") + } + host, _, err := net.SplitHostPort(cfg.Addr) + if err != nil || cfg.Net != "tcp" || (host != "127.0.0.1" && host != "localhost" && host != "::1") || cfg.DBName != "" { + t.Fatal("IM tests require a loopback TCP DSN without a database name") + } + cfg.ParseTime, cfg.MultiStatements = true, true + admin, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { admin.Close() }) + name := fmt.Sprintf("im_unread_test_%d", time.Now().UnixNano()) + if !regexp.MustCompile(`^im_unread_test_[0-9]+$`).MatchString(name) { + t.Fatal("unsafe test schema") + } + if _, err := admin.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := admin.Exec("DROP DATABASE `" + name + "`"); err != nil { + t.Errorf("test database cleanup: %v", err) + } + }) + cfg.DBName = name + db, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + files, err := filepath.Glob(filepath.Join("..", "..", "migrations", "*.sql")) + if err != nil { + t.Fatal(err) + } + for _, file := range files { + if filepath.Base(file)[:3] > "026" { + continue + } + body, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(string(body)); err != nil { + t.Fatalf("migration %s: %v", file, err) + } + } + for id := 1; id <= 3; id++ { + for _, query := range []string{ + `INSERT INTO users(id,public_id,password_hash) VALUES(?,CONCAT('IMTEST',?),'!NO_LOGIN')`, + `INSERT INTO user_profiles(user_id,nickname) VALUES(?,CONCAT('IM测试',?))`, + `INSERT INTO user_privacy_settings(user_id) VALUES(?)`, + } { + args := []any{id} + if strings.Count(query, "?") == 2 { + args = append(args, id) + } + if _, err := db.Exec(query, args...); err != nil { + t.Fatal(err) + } + } + } + return db +} + +func imTestRequest(user int64, method, path, body string) *http.Request { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + return r.WithContext(context.WithValue(r.Context(), identityKey{}, identity{ID: user, Role: "user"})) +} + +func imTestCall(t *testing.T, handler http.HandlerFunc, user int64, method, path, body string, wantStatus int) json.RawMessage { + t.Helper() + w := httptest.NewRecorder() + handler(w, imTestRequest(user, method, path, body)) + if w.Code != wantStatus { + t.Fatalf("%s %s user %d: HTTP %d: %s", method, path, user, w.Code, w.Body.String()) + } + var response struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + return response.Data +} + +func TestIMUnreadMySQL(t *testing.T) { + db := isolatedIMDatabase(t) + a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} + data := imTestCall(t, a.directConversation, 1, "POST", "/api/v1/im/conversations", `{"userId":2}`, 200) + var conversation struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(data, &conversation); err != nil { + t.Fatal(err) + } + id := conversation.ID + messagePath := fmt.Sprintf("/api/v1/im/conversations/%d/messages", id) + settingsPath := fmt.Sprintf("/api/v1/im/conversations/%d/settings", id) + unread := func(user int64, want int, empty bool) { + t.Helper() + var list struct { + Items []struct { + Unread int `json:"unread"` + LastMessageAt *string `json:"lastMessageAt"` + } `json:"items"` + } + if err := json.Unmarshal(imTestCall(t, a.conversations, user, "GET", "/api/v1/im/conversations", "", 200), &list); err != nil { + t.Fatal(err) + } + if len(list.Items) != 1 || list.Items[0].Unread != want { + t.Fatalf("user %d unread: %+v, want %d", user, list, want) + } + if empty { + if list.Items[0].LastMessageAt != nil { + t.Fatal("empty conversation time must be null") + } + return + } + if list.Items[0].LastMessageAt == nil { + t.Fatal("missing message timestamp") + } + if _, err := time.Parse(time.RFC3339Nano, *list.Items[0].LastMessageAt); err != nil { + t.Fatal("message time must be RFC3339, not sql.NullTime JSON") + } + } + send := func(user int64, key string) messageView { + t.Helper() + var message messageView + payload := fmt.Sprintf(`{"clientMsgId":%q,"type":1,"content":{"text":"hello"}}`, key) + if err := json.Unmarshal(imTestCall(t, a.sendMessageHTTP, user, "POST", messagePath, payload, 200), &message); err != nil { + t.Fatal(err) + } + return message + } + t.Run("empty conversation has no unread and null time", func(t *testing.T) { unread(1, 0, true); unread(2, 0, true) }) + t.Run("sender zero recipient one", func(t *testing.T) { send(1, "a1"); unread(1, 0, false); unread(2, 1, false) }) + t.Run("reply does not mark earlier incoming messages read", func(t *testing.T) { + first := send(2, "b1") + again := send(2, "b1") + if first.ID != again.ID { + t.Fatal("retry must be idempotent") + } + unread(1, 1, false) + unread(2, 1, false) + send(1, "a2") + unread(1, 1, false) + unread(2, 2, false) + }) + t.Run("older history cannot read newer incoming messages", func(t *testing.T) { + imTestCall(t, a.messages, 1, "GET", messagePath+"?beforeSeq=2&limit=1", "", 200) + unread(1, 1, false) + }) + t.Run("latest history clears only the reader", func(t *testing.T) { + imTestCall(t, a.messages, 2, "GET", messagePath+"?limit=1", "", 200) + unread(1, 1, false) + unread(2, 0, false) + }) + t.Run("read permissions and future sequence clamp", func(t *testing.T) { + imTestCall(t, a.conversationSettings, 3, "PUT", settingsPath, `{"readSeq":999}`, 403) + imTestCall(t, a.conversationSettings, 1, "PUT", settingsPath, `{"readSeq":-1}`, 400) + imTestCall(t, a.conversationSettings, 1, "PUT", settingsPath, `{"readSeq":999}`, 200) + var read int64 + if err := db.QueryRow(`SELECT read_seq FROM im_conversation_members WHERE conversation_id=? AND user_id=1`, id).Scan(&read); err != nil || read != 3 { + t.Fatalf("read marker %d: %v", read, err) + } + unread(1, 0, false) + m := send(2, "b2") + unread(1, 1, false) + unread(2, 0, false) + imTestCall(t, a.recallMessage, 2, "POST", fmt.Sprintf("/api/v1/im/messages/%d/recall", m.ID), "", 200) + unread(1, 0, false) + }) + t.Run("moderated and cleared messages are not unread", func(t *testing.T) { + m := send(1, "a3") + unread(2, 1, false) + if _, err := db.Exec(`UPDATE im_messages SET admin_removed_at=NOW(3) WHERE id=?`, m.ID); err != nil { + t.Fatal(err) + } + unread(2, 0, false) + if _, err := db.Exec(`UPDATE im_messages SET admin_removed_at=NULL WHERE id=?`, m.ID); err != nil { + t.Fatal(err) + } + unread(2, 1, false) + if _, err := db.Exec(`UPDATE im_conversation_members SET clear_seq=? WHERE conversation_id=? AND user_id=2`, m.Seq, id); err != nil { + t.Fatal(err) + } + unread(2, 0, false) + }) + t.Run("websocket send echoes are persisted once and HTTP read broadcasts ack", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(a.websocket)) + defer server.Close() + token, err := a.token(1, "user", "fixture", time.Minute) + if err != nil { + t.Fatal(err) + } + dialer := websocket.Dialer{Subprotocols: []string{"xingyu.jwt." + token}} + conn, _, err := dialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + readFrame := func() map[string]any { + t.Helper() + conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + var frame map[string]any + if err := conn.ReadJSON(&frame); err != nil { + t.Fatal(err) + } + return frame + } + if readFrame()["command"] != "AUTH_ACK" { + t.Fatal("missing auth ack") + } + if err := conn.WriteJSON(map[string]any{"command": "SEND_MESSAGE", "conversationId": id, "clientMsgId": "ws-a4", "type": 1, "content": map[string]any{"text": "ws hello"}}); err != nil { + t.Fatal(err) + } + if readFrame()["command"] != "MESSAGE_PUSH" { + t.Fatal("missing sender echo") + } + unread(1, 0, false) + unread(2, 1, false) + imTestCall(t, a.messages, 1, "GET", messagePath, "", 200) + if readFrame()["command"] != "READ_ACK" { + t.Fatal("HTTP read must sync this user's other device") + } + if err := conn.WriteJSON(map[string]any{"command": "READ", "conversationId": id, "readSeq": 999}); err != nil { + t.Fatal(err) + } + if readFrame()["command"] != "READ_ACK" { + t.Fatal("missing ws read ack") + } + send(2, "b3") + if readFrame()["command"] != "MESSAGE_PUSH" { + t.Fatal("missing incoming push") + } + unread(1, 1, false) + }) +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/im_validation_test.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/im_validation_test.go new file mode 100644 index 0000000..0561ad3 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/im_validation_test.go @@ -0,0 +1,39 @@ +package app + +import ( + "strings" + "testing" +) + +func TestValidateMessagePayload(t *testing.T) { + application := &App{config: Config{Environment: "development"}} + clientID, content, body, err := application.validateMessagePayload("client-1", 1, map[string]any{"text": " 你好 "}) + if err != nil { + t.Fatalf("expected valid text message: %v", err) + } + if clientID != "client-1" || content.(map[string]any)["text"] != "你好" || len(body) == 0 { + t.Fatalf("message was not normalized: %#v %#v", clientID, content) + } + if _, _, _, err = application.validateMessagePayload("bad id", 1, map[string]any{"text": "hello"}); err == nil { + t.Fatal("client message IDs containing spaces must be rejected") + } + if _, _, _, err = application.validateMessagePayload("client-2", 1, map[string]any{"text": strings.Repeat("好", maxMessageTextRunes+1)}); err == nil { + t.Fatal("oversized text messages must be rejected") + } + if _, _, _, err = application.validateMessagePayload("client-3", 3, map[string]any{"duration": 61.0, "url": "/uploads/voice.mp3"}); err == nil { + t.Fatal("voice messages longer than 60 seconds must be rejected") + } +} + +func TestProductionMessageMediaRequiresHTTPS(t *testing.T) { + application := &App{config: Config{Environment: "production"}} + if _, _, _, err := application.validateMessagePayload("client-1", 2, map[string]any{"url": "http://cdn.example.com/a.jpg"}); err == nil { + t.Fatal("production media URLs must use HTTPS") + } + if _, _, _, err := application.validateMessagePayload("client-2", 2, map[string]any{"url": "https://cdn.example.com/a.jpg"}); err != nil { + t.Fatalf("valid HTTPS media URL rejected: %v", err) + } + if validMessageMediaURL("/uploads/../secret", true) { + t.Fatal("local media paths must not allow traversal") + } +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/integration.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/integration.go new file mode 100644 index 0000000..257876d --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/integration.go @@ -0,0 +1,602 @@ +package app + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +const maskedSecret = "••••••••" + +type integrationFieldSpec struct { + Key string + Label string + Input string + Required bool + Options []string + Providers []string + Description string +} + +type integrationFieldView struct { + Key string `json:"key"` + Label string `json:"label"` + Value string `json:"value"` + Input string `json:"input"` + Secret bool `json:"secret"` + HasValue bool `json:"hasValue"` + Required bool `json:"required"` + Options []string `json:"options,omitempty"` + Providers []string `json:"providers,omitempty"` + Description string `json:"description"` +} + +var integrationSpecs = map[string][]integrationFieldSpec{ + "oauth": { + {Key: "oauth.admin.frontend_callback_url", Label: "管理端登录结果页", Input: "text", Description: "管理端启用第三方登录时必填;生产环境必须使用 HTTPS"}, + {Key: "oauth.user.frontend_callback_url", Label: "客户端 H5 登录结果页", Input: "text", Description: "H5 启用第三方登录时必填;生产环境必须使用 HTTPS"}, + {Key: "oauth.app.frontend_callback_url", Label: "App GitHub 登录结果地址", Input: "text", Providers: []string{"github"}, Description: "固定为 xingyuim://oauth/callback;Android/iOS 打包须注册 xingyuim URL Scheme,GitHub 平台仍填写后端 HTTPS 回调地址"}, + {Key: "oauth.app.wechat.enabled", Label: "App 启用微信登录", Input: "boolean", Required: true, Providers: []string{"wechat"}, Description: "后端控制 App 原生微信登录;还需在 manifest 中配置微信 SDK 并重新打包"}, + {Key: "oauth.app.qq.enabled", Label: "App 启用 QQ 登录", Input: "boolean", Required: true, Providers: []string{"qq"}, Description: "后端控制 App 原生 QQ 登录;还需在 manifest 中配置 QQ SDK 并重新打包"}, + {Key: "oauth.app.github.enabled", Label: "App 启用 GitHub 登录", Input: "boolean", Required: true, Providers: []string{"github"}, Description: "系统浏览器授权后返回 App;复用下方 GitHub OAuth 应用参数"}, + {Key: "oauth.app.google.enabled", Label: "App 启用 Google 登录", Input: "boolean", Required: true, Providers: []string{"google"}, Description: "后端控制 App 原生 Google 登录;需配置 Google SDK 和允许的客户端 ID"}, + {Key: "oauth.app.wechat.client_id", Label: "App 微信 AppID", Input: "text", Providers: []string{"wechat"}, Description: "微信开放平台移动应用 AppID,需与 App 打包配置一致;独立于网站应用"}, + {Key: "oauth.app.wechat.client_secret", Label: "App 微信 AppSecret", Input: "secret", Providers: []string{"wechat"}, Description: "移动应用密钥,AES-GCM 加密保存;不得写入 App 包"}, + {Key: "oauth.app.qq.client_id", Label: "App QQ AppID", Input: "text", Providers: []string{"qq"}, Description: "QQ 互联移动应用 AppID,需与 App 打包配置一致"}, + {Key: "oauth.app.google.client_ids", Label: "App Google Client ID 白名单", Input: "text", Providers: []string{"google"}, Description: "Android/iOS OAuth 客户端 ID,以英文逗号分隔;后端验证令牌所属应用"}, + + {Key: "oauth.wechat.enabled", Label: "管理端启用微信登录", Input: "boolean", Required: true, Providers: []string{"wechat"}, Description: "开启后且必填参数完整时,管理端登录页显示微信入口"}, + {Key: "oauth.user.wechat.enabled", Label: "H5 启用微信登录", Input: "boolean", Required: true, Providers: []string{"wechat"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示微信入口"}, + {Key: "oauth.wechat.client_id", Label: "微信 AppID", Input: "text", Providers: []string{"wechat"}, Description: "微信开放平台网站应用 AppID"}, + {Key: "oauth.wechat.client_secret", Label: "微信 AppSecret", Input: "secret", Providers: []string{"wechat"}, Description: "AES-GCM 加密保存"}, + {Key: "oauth.wechat.authorization_url", Label: "微信授权地址", Input: "text", Providers: []string{"wechat"}, Description: "默认使用微信开放平台 qrconnect 地址"}, + {Key: "oauth.wechat.token_url", Label: "微信令牌地址", Input: "text", Providers: []string{"wechat"}, Description: "授权码换取 access_token 的地址"}, + {Key: "oauth.wechat.userinfo_url", Label: "微信用户信息地址", Input: "text", Providers: []string{"wechat"}, Description: "获取登录用户 OpenID 与资料的地址"}, + {Key: "oauth.wechat.scope", Label: "微信授权范围", Input: "text", Providers: []string{"wechat"}, Description: "网站扫码登录通常为 snsapi_login"}, + {Key: "oauth.wechat.redirect_uri", Label: "微信回调地址", Input: "text", Providers: []string{"wechat"}, Description: "必须与微信开放平台登记值完全一致"}, + + {Key: "oauth.qq.enabled", Label: "管理端启用 QQ 登录", Input: "boolean", Required: true, Providers: []string{"qq"}, Description: "开启后且必填参数完整时,管理端登录页显示 QQ 入口"}, + {Key: "oauth.user.qq.enabled", Label: "H5 启用 QQ 登录", Input: "boolean", Required: true, Providers: []string{"qq"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示 QQ 入口"}, + {Key: "oauth.qq.client_id", Label: "QQ AppID", Input: "text", Providers: []string{"qq"}, Description: "QQ 互联应用 AppID"}, + {Key: "oauth.qq.client_secret", Label: "QQ AppKey", Input: "secret", Providers: []string{"qq"}, Description: "AES-GCM 加密保存"}, + {Key: "oauth.qq.authorization_url", Label: "QQ 授权地址", Input: "text", Providers: []string{"qq"}, Description: "QQ OAuth 2.0 authorize 地址"}, + {Key: "oauth.qq.token_url", Label: "QQ 令牌地址", Input: "text", Providers: []string{"qq"}, Description: "授权码换取 access_token 的地址"}, + {Key: "oauth.qq.openid_url", Label: "QQ OpenID 地址", Input: "text", Providers: []string{"qq"}, Description: "使用 access_token 获取 QQ OpenID"}, + {Key: "oauth.qq.userinfo_url", Label: "QQ 用户信息地址", Input: "text", Providers: []string{"qq"}, Description: "获取昵称和头像"}, + {Key: "oauth.qq.scope", Label: "QQ 授权范围", Input: "text", Providers: []string{"qq"}, Description: "默认 get_user_info"}, + {Key: "oauth.qq.redirect_uri", Label: "QQ 回调地址", Input: "text", Providers: []string{"qq"}, Description: "必须与 QQ 互联登记值完全一致"}, + + {Key: "oauth.github.enabled", Label: "管理端启用 GitHub 登录", Input: "boolean", Required: true, Providers: []string{"github"}, Description: "开启后且必填参数完整时,管理端登录页显示 GitHub 入口"}, + {Key: "oauth.user.github.enabled", Label: "H5 启用 GitHub 登录", Input: "boolean", Required: true, Providers: []string{"github"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示 GitHub 入口"}, + {Key: "oauth.github.client_id", Label: "GitHub Client ID", Input: "text", Providers: []string{"github"}, Description: "GitHub OAuth App Client ID"}, + {Key: "oauth.github.client_secret", Label: "GitHub Client Secret", Input: "secret", Providers: []string{"github"}, Description: "AES-GCM 加密保存"}, + {Key: "oauth.github.authorization_url", Label: "GitHub 授权地址", Input: "text", Providers: []string{"github"}, Description: "GitHub OAuth authorize 地址"}, + {Key: "oauth.github.token_url", Label: "GitHub 令牌地址", Input: "text", Providers: []string{"github"}, Description: "授权码换取 access_token 的地址"}, + {Key: "oauth.github.userinfo_url", Label: "GitHub 用户信息地址", Input: "text", Providers: []string{"github"}, Description: "默认使用 /user 接口"}, + {Key: "oauth.github.scope", Label: "GitHub 授权范围", Input: "text", Providers: []string{"github"}, Description: "建议仅 read:user user:email"}, + {Key: "oauth.github.redirect_uri", Label: "GitHub 回调地址", Input: "text", Providers: []string{"github"}, Description: "必须与 OAuth App 的 callback URL 完全一致"}, + + {Key: "oauth.google.enabled", Label: "管理端启用 Google 登录", Input: "boolean", Required: true, Providers: []string{"google"}, Description: "开启后且必填参数完整时,管理端登录页显示 Google 入口"}, + {Key: "oauth.user.google.enabled", Label: "H5 启用 Google 登录", Input: "boolean", Required: true, Providers: []string{"google"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示 Google 入口"}, + {Key: "oauth.google.client_id", Label: "Google Client ID", Input: "text", Providers: []string{"google"}, Description: "Google OAuth 2.0 Client ID"}, + {Key: "oauth.google.client_secret", Label: "Google Client Secret", Input: "secret", Providers: []string{"google"}, Description: "AES-GCM 加密保存"}, + {Key: "oauth.google.authorization_url", Label: "Google 授权地址", Input: "text", Providers: []string{"google"}, Description: "Google OAuth authorization endpoint"}, + {Key: "oauth.google.token_url", Label: "Google 令牌地址", Input: "text", Providers: []string{"google"}, Description: "Google OAuth token endpoint"}, + {Key: "oauth.google.userinfo_url", Label: "Google UserInfo 地址", Input: "text", Providers: []string{"google"}, Description: "OpenID Connect UserInfo endpoint"}, + {Key: "oauth.google.scope", Label: "Google 授权范围", Input: "text", Providers: []string{"google"}, Description: "至少包含 openid profile email"}, + {Key: "oauth.google.redirect_uri", Label: "Google 回调地址", Input: "text", Providers: []string{"google"}, Description: "必须与 Google Cloud Console 登记值完全一致"}, + }, + "storage": { + {Key: "storage.provider", Label: "当前存储厂商", Input: "select", Required: true, Options: []string{"local", "aliyun_oss", "tencent_cos", "qiniu", "huawei_obs", "huawei_flexus"}, Description: "保存后所有新上传文件立即切换到所选存储;历史文件地址不受影响"}, + {Key: "storage.object_prefix", Label: "云端对象前缀", Input: "text", Required: true, Description: "仅允许字母、数字、斜杠、下划线和短横线,例如 media"}, + + {Key: "storage.local.directory", Label: "本地存储目录", Input: "text", Required: true, Providers: []string{"local"}, Description: "相对路径基于后端运行目录;禁止配置为磁盘根目录"}, + {Key: "storage.local.public_base_url", Label: "本地公开访问地址", Input: "text", Providers: []string{"local"}, Description: "可选,例如 https://api.example.com/uploads;留空时根据当前请求生成"}, + + {Key: "storage.aliyun_oss.endpoint", Label: "阿里云 OSS Endpoint", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "例如 https://oss-cn-hangzhou.aliyuncs.com"}, + {Key: "storage.aliyun_oss.region", Label: "阿里云 OSS Region", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "例如 cn-hangzhou"}, + {Key: "storage.aliyun_oss.bucket", Label: "阿里云 OSS Bucket", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "需要 oss:PutObject 权限"}, + {Key: "storage.aliyun_oss.access_key_id", Label: "阿里云 AccessKey ID", Input: "secret", Required: true, Providers: []string{"aliyun_oss"}, Description: "建议使用最小权限 RAM 用户,AES-GCM 加密保存"}, + {Key: "storage.aliyun_oss.access_key_secret", Label: "阿里云 AccessKey Secret", Input: "secret", Required: true, Providers: []string{"aliyun_oss"}, Description: "AES-GCM 加密保存"}, + {Key: "storage.aliyun_oss.public_base_url", Label: "阿里云文件访问域名", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "Bucket 公网域名或已配置的 CDN/自定义域名,必须使用 HTTPS"}, + + {Key: "storage.tencent_cos.endpoint", Label: "腾讯云 COS Bucket URL", Input: "text", Required: true, Providers: []string{"tencent_cos"}, Description: "例如 https://bucket-appid.cos.ap-guangzhou.myqcloud.com"}, + {Key: "storage.tencent_cos.bucket", Label: "腾讯云 COS Bucket", Input: "text", Required: true, Providers: []string{"tencent_cos"}, Description: "完整名称需包含 APPID,需要 cos:PutObject 权限"}, + {Key: "storage.tencent_cos.secret_id", Label: "腾讯云 SecretId", Input: "secret", Required: true, Providers: []string{"tencent_cos"}, Description: "建议使用最小权限 CAM 子账号密钥,AES-GCM 加密保存"}, + {Key: "storage.tencent_cos.secret_key", Label: "腾讯云 SecretKey", Input: "secret", Required: true, Providers: []string{"tencent_cos"}, Description: "AES-GCM 加密保存"}, + {Key: "storage.tencent_cos.public_base_url", Label: "腾讯云文件访问域名", Input: "text", Required: true, Providers: []string{"tencent_cos"}, Description: "Bucket 公网域名或 CDN 域名,必须使用 HTTPS"}, + + {Key: "storage.qiniu.bucket", Label: "七牛云空间名称", Input: "text", Required: true, Providers: []string{"qiniu"}, Description: "Kodo Bucket 名称,SDK 自动发现上传区域"}, + {Key: "storage.qiniu.access_key", Label: "七牛云 AccessKey", Input: "secret", Required: true, Providers: []string{"qiniu"}, Description: "建议使用仅具备目标空间上传权限的密钥,AES-GCM 加密保存"}, + {Key: "storage.qiniu.secret_key", Label: "七牛云 SecretKey", Input: "secret", Required: true, Providers: []string{"qiniu"}, Description: "AES-GCM 加密保存"}, + {Key: "storage.qiniu.public_base_url", Label: "七牛云文件访问域名", Input: "text", Required: true, Providers: []string{"qiniu"}, Description: "空间绑定域名或 CDN 域名,必须使用 HTTPS"}, + + {Key: "storage.huawei_obs.endpoint", Label: "华为云 OBS Endpoint", Input: "text", Required: true, Providers: []string{"huawei_obs"}, Description: "例如 https://obs.cn-north-4.myhuaweicloud.com"}, + {Key: "storage.huawei_obs.bucket", Label: "华为云 OBS Bucket", Input: "text", Required: true, Providers: []string{"huawei_obs"}, Description: "需要 obs:object:PutObject 权限"}, + {Key: "storage.huawei_obs.access_key", Label: "华为云 OBS Access Key", Input: "secret", Required: true, Providers: []string{"huawei_obs"}, Description: "IAM 用户 AK,AES-GCM 加密保存"}, + {Key: "storage.huawei_obs.secret_key", Label: "华为云 OBS Secret Key", Input: "secret", Required: true, Providers: []string{"huawei_obs"}, Description: "IAM 用户 SK,AES-GCM 加密保存"}, + {Key: "storage.huawei_obs.public_base_url", Label: "华为云 OBS 文件访问域名", Input: "text", Required: true, Providers: []string{"huawei_obs"}, Description: "Bucket 公网域名或 CDN 域名,必须使用 HTTPS"}, + + {Key: "storage.huawei_flexus.endpoint", Label: "Flexus 对象存储 Endpoint", Input: "text", Required: true, Providers: []string{"huawei_flexus"}, Description: "Flexus 对象存储控制台提供的 OBS 兼容 Endpoint"}, + {Key: "storage.huawei_flexus.bucket", Label: "Flexus Bucket", Input: "text", Required: true, Providers: []string{"huawei_flexus"}, Description: "Flexus 对象存储桶名称"}, + {Key: "storage.huawei_flexus.access_key", Label: "Flexus Access Key", Input: "secret", Required: true, Providers: []string{"huawei_flexus"}, Description: "AES-GCM 加密保存"}, + {Key: "storage.huawei_flexus.secret_key", Label: "Flexus Secret Key", Input: "secret", Required: true, Providers: []string{"huawei_flexus"}, Description: "AES-GCM 加密保存"}, + {Key: "storage.huawei_flexus.public_base_url", Label: "Flexus 文件访问域名", Input: "text", Required: true, Providers: []string{"huawei_flexus"}, Description: "Bucket 公网域名或 CDN 域名,必须使用 HTTPS"}, + }, + "sms": { + {Key: "sms.enabled", Label: "启用短信服务", Input: "boolean", Required: true, Description: "关闭后将拒绝发送验证码"}, + {Key: "sms.provider", Label: "当前短信厂商", Input: "select", Required: true, Options: []string{"aliyun", "tencent", "huawei", "webhook", "debug"}, Description: "保存后新验证码立即切换到所选厂商;debug 仅限本地开发"}, + {Key: "sms.expire_seconds", Label: "有效期(秒)", Input: "number", Required: true, Description: "建议 120 至 600 秒"}, + {Key: "sms.debug_code", Label: "调试验证码", Input: "secret", Required: true, Providers: []string{"debug"}, Description: "仅 debug 模式返回给客户端"}, + + {Key: "sms.aliyun.endpoint", Label: "阿里云 API 地址", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "国内短信默认 https://dysmsapi.aliyuncs.com"}, + {Key: "sms.aliyun.access_key_id", Label: "阿里云 AccessKey ID", Input: "secret", Required: true, Providers: []string{"aliyun"}, Description: "建议使用仅授予短信发送权限的 RAM 用户"}, + {Key: "sms.aliyun.access_key_secret", Label: "阿里云 AccessKey Secret", Input: "secret", Required: true, Providers: []string{"aliyun"}, Description: "AES-GCM 加密保存"}, + {Key: "sms.aliyun.sign_name", Label: "阿里云短信签名", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "必须是审核通过的签名名称"}, + {Key: "sms.aliyun.template_register", Label: "阿里云注册模板 Code", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "注册验证码模板,例如 SMS_123456789"}, + {Key: "sms.aliyun.template_login", Label: "阿里云登录模板 Code", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "短信登录验证码模板"}, + {Key: "sms.aliyun.template_reset", Label: "阿里云重置密码模板 Code", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "找回密码验证码模板"}, + {Key: "sms.aliyun.template_params", Label: "阿里云模板变量 JSON", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "支持 {{code}} 与 {{minutes}},例如 {\"code\":\"{{code}}\"}"}, + + {Key: "sms.tencent.endpoint", Label: "腾讯云 API 地址", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "国内短信默认 https://sms.tencentcloudapi.com"}, + {Key: "sms.tencent.secret_id", Label: "腾讯云 SecretId", Input: "secret", Required: true, Providers: []string{"tencent"}, Description: "建议使用最小权限 CAM 子账号密钥"}, + {Key: "sms.tencent.secret_key", Label: "腾讯云 SecretKey", Input: "secret", Required: true, Providers: []string{"tencent"}, Description: "AES-GCM 加密保存"}, + {Key: "sms.tencent.sdk_app_id", Label: "腾讯云短信 SdkAppId", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "短信控制台应用 ID"}, + {Key: "sms.tencent.region", Label: "腾讯云地域", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "国内短信建议 ap-guangzhou"}, + {Key: "sms.tencent.sign_name", Label: "腾讯云短信签名", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "必须是审核通过的签名内容"}, + {Key: "sms.tencent.template_register", Label: "腾讯云注册模板 ID", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "注册验证码模板 ID"}, + {Key: "sms.tencent.template_login", Label: "腾讯云登录模板 ID", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "短信登录验证码模板 ID"}, + {Key: "sms.tencent.template_reset", Label: "腾讯云重置密码模板 ID", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "找回密码验证码模板 ID"}, + {Key: "sms.tencent.template_params", Label: "腾讯云模板参数 JSON", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "参数按模板变量顺序排列,例如 [\"{{code}}\"]"}, + + {Key: "sms.huawei.endpoint", Label: "华为云 APP 接入地址", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "填写控制台提供的 HTTPS 地址,包含 /sms/batchSendSms/v1"}, + {Key: "sms.huawei.app_key", Label: "华为云 Application Key", Input: "secret", Required: true, Providers: []string{"huawei"}, Description: "短信应用的 APP_Key"}, + {Key: "sms.huawei.app_secret", Label: "华为云 Application Secret", Input: "secret", Required: true, Providers: []string{"huawei"}, Description: "短信应用的 APP_Secret,AES-GCM 加密保存"}, + {Key: "sms.huawei.sender", Label: "华为云签名通道号", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "国内短信签名审核后分配的通道号"}, + {Key: "sms.huawei.signature", Label: "华为云签名名称", Input: "text", Providers: []string{"huawei"}, Description: "通用模板需要填写已审核签名;非通用模板可留空"}, + {Key: "sms.huawei.template_register", Label: "华为云注册模板 ID", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "注册验证码模板 ID"}, + {Key: "sms.huawei.template_login", Label: "华为云登录模板 ID", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "短信登录验证码模板 ID"}, + {Key: "sms.huawei.template_reset", Label: "华为云重置密码模板 ID", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "找回密码验证码模板 ID"}, + {Key: "sms.huawei.template_params", Label: "华为云模板参数 JSON", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "参数按模板变量顺序排列,例如 [\"{{code}}\"]"}, + {Key: "sms.huawei.status_callback", Label: "华为云状态回调地址", Input: "text", Providers: []string{"huawei"}, Description: "可选,接收运营商最终送达状态"}, + + {Key: "sms.sign_name", Label: "Webhook 短信签名", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "发送给自建网关的签名名称"}, + {Key: "sms.template_register", Label: "Webhook 注册模板 ID", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "注册场景模板"}, + {Key: "sms.template_login", Label: "Webhook 登录模板 ID", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "验证码登录场景模板"}, + {Key: "sms.template_reset", Label: "Webhook 找回密码模板 ID", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "重置密码场景模板"}, + {Key: "sms.webhook_url", Label: "Webhook 地址", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "接收 JSON POST;生产环境必须使用 HTTPS"}, + {Key: "sms.webhook_token", Label: "Webhook Token", Input: "secret", Providers: []string{"webhook"}, Description: "以 Bearer Token 发送,仅显示保存状态"}, + }, + "payment": { + {Key: "payment.mode", Label: "支付模式", Input: "select", Required: true, Options: []string{"sandbox", "live"}, Description: "sandbox 可直接完成本地支付闭环"}, + {Key: "payment.gateway.create_url", Label: "支付网关下单地址", Input: "text", Description: "live 模式必填,生产环境必须使用 HTTPS"}, + {Key: "payment.gateway.refund_url", Label: "支付网关退款地址", Input: "text", Description: "live 模式退款必填,生产环境必须使用 HTTPS"}, + {Key: "payment.gateway.token", Label: "支付网关访问令牌", Input: "secret", Description: "live 模式必填,以 Bearer Token 调用统一支付网关"}, + {Key: "payment.gateway.notify_secret", Label: "支付回调签名密钥", Input: "secret", Description: "live 模式必填,至少 32 位随机字符串,用于 HMAC-SHA256 验签"}, + {Key: "payment.gateway.notify_url", Label: "支付异步回调地址", Input: "text", Description: "live 模式必填,例如 https://api.example.com/api/v1/payment/notify"}, + {Key: "payment.gateway.return_url", Label: "支付完成返回地址", Input: "text", Description: "H5 支付完成后返回的客户端地址"}, + {Key: "payment.gateway.timeout_seconds", Label: "网关超时(秒)", Input: "number", Description: "建议 5 至 30 秒"}, + {Key: "payment.alipay.enabled", Label: "启用支付宝", Input: "boolean", Required: true, Description: "控制客户端支付宝入口"}, + {Key: "payment.alipay.app_id", Label: "支付宝 APPID", Input: "text", Description: "开放平台应用 APPID"}, + {Key: "payment.alipay.private_key", Label: "支付宝应用私钥", Input: "secret", Description: "敏感字段加密存储"}, + {Key: "payment.alipay.public_key", Label: "支付宝公钥", Input: "secret", Description: "用于验签"}, + {Key: "payment.alipay.notify_url", Label: "支付宝通知地址", Input: "text", Description: "必须是公网 HTTPS 地址"}, + {Key: "payment.wechat.enabled", Label: "启用微信支付", Input: "boolean", Required: true, Description: "控制客户端微信支付入口"}, + {Key: "payment.wechat.app_id", Label: "微信 AppID", Input: "text", Description: "移动应用或小程序 AppID"}, + {Key: "payment.wechat.mch_id", Label: "微信商户号", Input: "text", Description: "微信支付商户号"}, + {Key: "payment.wechat.api_v3_key", Label: "APIv3 密钥", Input: "secret", Description: "敏感字段加密存储"}, + {Key: "payment.wechat.private_key", Label: "商户私钥", Input: "secret", Description: "PEM 内容,敏感字段加密存储"}, + {Key: "payment.wechat.serial_no", Label: "证书序列号", Input: "text", Description: "商户 API 证书序列号"}, + {Key: "payment.wechat.notify_url", Label: "微信通知地址", Input: "text", Description: "必须是公网 HTTPS 地址"}, + }, +} + +func (a *App) configPlain(ctx context.Context, key, fallback string) string { + var value, valueType string + if err := a.db.QueryRowContext(ctx, `SELECT config_value,value_type FROM system_configs WHERE config_key=?`, key).Scan(&value, &valueType); err != nil { + return fallback + } + if valueType == "secret" && value != "" { + plain, err := a.decryptSecret(value) + if err != nil { + return fallback + } + return plain + } + return value +} + +func (a *App) configBool(ctx context.Context, key string, fallback bool) bool { + value := strings.ToLower(a.configPlain(ctx, key, strconv.FormatBool(fallback))) + return value == "1" || value == "true" || value == "yes" || value == "on" +} + +func (a *App) encryptSecret(plain string) (string, error) { + if plain == "" { + return "", nil + } + key := sha256.Sum256([]byte(a.integrationEncryptionKey())) + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + sealed := gcm.Seal(nonce, nonce, []byte(plain), nil) + return "enc:v1:" + base64.RawStdEncoding.EncodeToString(sealed), nil +} + +func (a *App) decryptSecret(value string) (string, error) { + if value == "" { + return "", nil + } + if !strings.HasPrefix(value, "enc:v1:") { + return value, nil + } + encoded := strings.TrimPrefix(value, "enc:v1:") + payload, err := base64.RawStdEncoding.DecodeString(encoded) + if err != nil { + return "", err + } + key := sha256.Sum256([]byte(a.integrationEncryptionKey())) + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil || len(payload) < gcm.NonceSize() { + return "", fmt.Errorf("invalid encrypted secret") + } + returnValue, err := gcm.Open(nil, payload[:gcm.NonceSize()], payload[gcm.NonceSize():], nil) + if err == nil { + return string(returnValue), nil + } + if a.config.ConfigEncryptionKey != "" { + legacyKey := sha256.Sum256([]byte(a.config.JWTSecret + ":integration-config")) + legacyBlock, legacyErr := aes.NewCipher(legacyKey[:]) + if legacyErr == nil { + legacyGCM, legacyErr := cipher.NewGCM(legacyBlock) + if legacyErr == nil && len(payload) >= legacyGCM.NonceSize() { + returnValue, legacyErr = legacyGCM.Open(nil, payload[:legacyGCM.NonceSize()], payload[legacyGCM.NonceSize():], nil) + if legacyErr == nil { + return string(returnValue), nil + } + } + } + } + return "", err +} + +func (a *App) integrationEncryptionKey() string { + if a.config.ConfigEncryptionKey != "" { + return a.config.ConfigEncryptionKey + } + // Development-only compatibility for databases created before the dedicated key existed. + return a.config.JWTSecret + ":integration-config" +} + +func integrationGroup(r *http.Request) string { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + for index, part := range parts { + if part == "integrations" && index+1 < len(parts) { + return parts[index+1] + } + } + return "" +} + +func (a *App) adminIntegration(w http.ResponseWriter, r *http.Request) { + group := integrationGroup(r) + specs, ok := integrationSpecs[group] + if !ok { + fail(w, http.StatusNotFound, 30001, "配置分组不存在") + return + } + fields := make([]integrationFieldView, 0, len(specs)) + configured := true + activeProvider := "" + if group == "sms" { + activeProvider = a.configPlain(r.Context(), "sms.provider", "debug") + configured = a.configBool(r.Context(), "sms.enabled", false) + } else if group == "storage" { + activeProvider = a.configPlain(r.Context(), "storage.provider", "local") + } else if group == "oauth" { + configured = a.oauthConfigurationReady(r.Context()) + } + for _, spec := range specs { + var value, valueType string + _ = a.db.QueryRowContext(r.Context(), `SELECT config_value,value_type FROM system_configs WHERE config_key=?`, spec.Key).Scan(&value, &valueType) + secret := valueType == "secret" || spec.Input == "secret" + hasValue := value != "" + displayValue := value + if secret { + displayValue = "" + } + applies := len(spec.Providers) == 0 || containsString(spec.Providers, activeProvider) + if group == "oauth" { + applies = false // OAuth 必填项由各渠道的启用状态独立校验。 + } + if applies && spec.Required && !hasValue { + configured = false + } + fields = append(fields, integrationFieldView{Key: spec.Key, Label: spec.Label, Value: displayValue, Input: spec.Input, Secret: secret, HasValue: hasValue, Required: spec.Required, Options: spec.Options, Providers: spec.Providers, Description: spec.Description}) + } + reply(w, map[string]any{"group": group, "configured": configured, "fields": fields, "secretMask": maskedSecret}) +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func (a *App) adminUpdateIntegration(w http.ResponseWriter, r *http.Request) { + group := integrationGroup(r) + specs, ok := integrationSpecs[group] + if !ok { + fail(w, http.StatusNotFound, 30001, "配置分组不存在") + return + } + var req struct { + Values map[string]string `json:"values"` + ClearSecrets []string `json:"clearSecrets"` + } + if decode(r, &req) != nil || req.Values == nil { + fail(w, http.StatusBadRequest, 20001, "配置格式错误") + return + } + allowed := map[string]integrationFieldSpec{} + for _, spec := range specs { + allowed[spec.Key] = spec + } + for key, value := range req.Values { + spec, exists := allowed[key] + if !exists { + fail(w, http.StatusBadRequest, 20001, "配置项不允许修改") + return + } + switch spec.Input { + case "select": + if !containsString(spec.Options, value) { + fail(w, http.StatusBadRequest, 20001, spec.Label+"选项无效") + return + } + case "boolean": + if value != "true" && value != "false" { + fail(w, http.StatusBadRequest, 20001, spec.Label+"必须为 true 或 false") + return + } + case "number": + number, parseErr := strconv.Atoi(value) + if parseErr != nil { + fail(w, http.StatusBadRequest, 20001, spec.Label+"必须是数字") + return + } + if key == "sms.expire_seconds" && (number < 60 || number > 1800) { + fail(w, http.StatusBadRequest, 20001, "短信有效期必须在 60 到 1800 秒之间") + return + } + if key == "payment.gateway.timeout_seconds" && (number < 3 || number > 30) { + fail(w, http.StatusBadRequest, 20001, "支付网关超时必须在 3 到 30 秒之间") + return + } + } + } + clearSet := map[string]bool{} + for _, key := range req.ClearSecrets { + spec, exists := allowed[key] + if !exists || spec.Input != "secret" { + fail(w, http.StatusBadRequest, 20001, "清除的配置项不是允许的密钥字段") + return + } + clearSet[key] = true + } + if group == "oauth" { + if err := a.validateAdminOAuthConfigValues(r.Context(), req.Values, clearSet); err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + defer func() { _ = tx.Rollback() }() + changed := []string{} + for key, value := range req.Values { + spec, exists := allowed[key] + if !exists { + continue + } + isSecret := spec.Input == "secret" + if isSecret && value == "" && !clearSet[key] { + continue + } + if isSecret && !clearSet[key] { + value, err = a.encryptSecret(value) + if err != nil { + fail(w, 500, 50001, "加密敏感配置失败") + return + } + } + if clearSet[key] { + value = "" + } + if _, err = tx.ExecContext(r.Context(), `UPDATE system_configs SET config_value=? WHERE config_key=?`, value, key); err != nil { + fail(w, 500, 50001, "保存失败") + return + } + changed = append(changed, key) + } + if err = tx.Commit(); err != nil { + fail(w, 500, 50001, "保存失败") + return + } + a.audit(r, "update", group+"_integration", 0, map[string]any{"changedKeys": changed}) + reply(w, map[string]any{"success": true, "changedKeys": changed}) +} + +func (a *App) adminTestIntegration(w http.ResponseWriter, r *http.Request) { + group := integrationGroup(r) + switch group { + case "sms": + if !a.configBool(r.Context(), "sms.enabled", false) { + fail(w, 400, 20001, "短信服务当前未启用") + return + } + provider := a.configPlain(r.Context(), "sms.provider", "debug") + if err := a.validateSMSProviderConfig(r.Context()); err != nil { + fail(w, 400, 20001, err.Error()) + return + } + reply(w, map[string]any{"success": true, "message": cloudSMSProviderName(provider) + "短信配置校验通过", "provider": provider}) + case "payment": + mode := a.configPlain(r.Context(), "payment.mode", "sandbox") + if a.config.Environment == "production" && mode != "live" { + fail(w, 400, 20001, "生产环境必须使用 live 支付模式") + return + } + if mode == "live" && !a.paymentGatewayConfigured(r.Context()) { + fail(w, 400, 20001, "统一支付网关配置不完整,请检查下单/退款/回调 HTTPS 地址、令牌和至少 32 位回调密钥") + return + } + channels := a.availablePaymentChannels(r.Context()) + if len(channels) == 0 { + fail(w, 400, 20001, "至少启用一个支付渠道") + return + } + if mode == "live" { + for _, channel := range channels { + if channel["configured"] != true { + fail(w, 400, 20001, fmt.Sprintf("%s 的生产参数不完整", channel["name"])) + return + } + } + } + reply(w, map[string]any{"success": true, "message": "支付配置校验通过", "mode": mode, "channels": channels}) + case "storage": + provider := a.configPlain(r.Context(), "storage.provider", "local") + if err := a.validateStorageProviderConfig(r.Context()); err != nil { + fail(w, 400, 20001, err.Error()) + return + } + reply(w, map[string]any{"success": true, "message": storageProviderName(provider) + "配置校验通过", "provider": provider}) + case "oauth": + adminProviders, err := a.enabledAdminOAuthProviders(r.Context()) + if err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + userProviders, err := a.enabledUserOAuthProviders(r.Context()) + if err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + appProviders, err := a.enabledAppOAuthProviders(r.Context()) + if err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + if len(userProviders) > 0 { + if _, err = a.userOAuthFrontendURL(r.Context()); err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + } + if len(adminProviders) > 0 { + if _, err = a.adminOAuthFrontendURL(r.Context()); err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + } + if len(adminProviders) == 0 && len(userProviders) == 0 && len(appProviders) == 0 { + reply(w, map[string]any{"success": true, "message": "当前未启用第三方登录,登录页不会显示第三方入口", "providers": []any{}}) + return + } + items := make([]map[string]string, 0, len(adminProviders)+len(userProviders)) + for _, provider := range adminProviders { + items = append(items, map[string]string{"audience": "admin", "code": provider.Code, "name": provider.Name}) + } + for _, provider := range userProviders { + items = append(items, map[string]string{"audience": "user", "code": provider.Code, "name": provider.Name}) + } + for _, provider := range appProviders { + items = append(items, map[string]string{"audience": "app", "code": provider.Code, "name": provider.Name}) + } + reply(w, map[string]any{"success": true, "message": "第三方登录配置校验通过", "providers": items}) + default: + fail(w, http.StatusNotFound, 30001, "配置分组不存在") + } +} + +func (a *App) dispatchSMS(ctx context.Context, phone, scene, code string) error { + provider := a.configPlain(ctx, "sms.provider", "debug") + if err := a.validateSMSProviderConfig(ctx); err != nil { + return err + } + switch provider { + case "debug": + return nil + case "aliyun": + _, err := a.sendAliyunSMS(ctx, phone, scene, code) + return err + case "tencent": + _, err := a.sendTencentSMS(ctx, phone, scene, code) + return err + case "huawei": + _, err := a.sendHuaweiSMS(ctx, phone, scene, code) + return err + case "webhook": + return a.sendWebhookSMS(ctx, phone, scene, code) + default: + return fmt.Errorf("不支持的短信提供商") + } +} + +func (a *App) sendWebhookSMS(ctx context.Context, phone, scene, code string) error { + endpoint := a.configPlain(ctx, "sms.webhook_url", "") + if endpoint == "" { + return fmt.Errorf("短信 Webhook 未配置") + } + templateID, err := a.smsTemplateID(ctx, "webhook", scene) + if err != nil { + return err + } + payload, _ := json.Marshal(map[string]any{"phone": phone, "scene": scene, "code": code, "signName": a.configPlain(ctx, "sms.sign_name", "星遇社交"), "templateId": templateID}) + request, _ := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + request.Header.Set("Content-Type", "application/json") + if token := a.configPlain(ctx, "sms.webhook_token", ""); token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + client := &http.Client{Timeout: 8 * time.Second} + response, err := client.Do(request) + if err != nil { + return fmt.Errorf("短信网关连接失败: %w", err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("短信网关返回 HTTP %d", response.StatusCode) + } + return nil +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/media.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/media.go new file mode 100644 index 0000000..c4d618b --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/media.go @@ -0,0 +1,213 @@ +package app + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log" + "net/http" + "path/filepath" + "regexp" + "strings" + "time" +) + +const maxUploadBytes int64 = 16 << 20 + +var mediaNamePattern = regexp.MustCompile(`^[0-9]+-[0-9]+(?:-av1(?:-thumb|-original)?)?\.(?:gif|jpe?g|png|webp|mp3|wav|amr|m4a)$`) + +func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) { + // Multipart boundaries and headers add a small amount of overhead. Keep the + // actual file limit at 16 MiB without rejecting a file that is exactly at + // that limit solely because of the multipart envelope. + r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+(1<<20)) + if err := r.ParseMultipartForm(maxUploadBytes); err != nil { + fail(w, http.StatusBadRequest, 20001, "媒体文件不得超过 16MB") + return + } + defer r.MultipartForm.RemoveAll() + purpose := r.FormValue("purpose") + if purpose != "" && purpose != "avatar" { + fail(w, http.StatusBadRequest, 20001, "上传用途无效") + return + } + + file, fileHeader, err := r.FormFile("file") + if err != nil { + fail(w, http.StatusBadRequest, 20001, "请选择媒体文件") + return + } + defer file.Close() + + size, err := file.Seek(0, io.SeekEnd) + if err != nil || size <= 0 || size > maxUploadBytes { + fail(w, http.StatusBadRequest, 20001, "媒体文件大小无效或超过 16MB") + return + } + if _, err = file.Seek(0, io.SeekStart); err != nil { + fail(w, http.StatusBadRequest, 20001, "无法读取媒体文件") + return + } + header := make([]byte, min(size, 512)) + read, err := io.ReadFull(file, header) + if err != nil && err != io.ErrUnexpectedEOF { + fail(w, http.StatusBadRequest, 20001, "无法读取媒体文件") + return + } + header = header[:read] + extensions := map[string]string{ + "image/gif": ".gif", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "audio/mpeg": ".mp3", + "audio/mp3": ".mp3", + "audio/wav": ".wav", + "audio/x-wav": ".wav", + "audio/amr": ".amr", + "audio/mp4": ".m4a", + } + contentType := http.DetectContentType(header) + extension, ok := extensions[contentType] + if !ok { + fail(w, http.StatusBadRequest, 20001, "仅支持常见图片或语音格式") + return + } + + if _, err = file.Seek(0, io.SeekStart); err != nil { + fail(w, http.StatusBadRequest, 20001, "无法读取媒体文件") + return + } + if err = a.validateStorageProviderConfig(r.Context()); err != nil { + fail(w, http.StatusServiceUnavailable, 50001, "文件存储尚未正确配置") + return + } + originalType, originalExtension, originalSize := contentType, extension, size + var avatar avatarImages + if purpose == "avatar" { + if !strings.HasPrefix(contentType, "image/") { + fail(w, http.StatusBadRequest, 20001, "头像必须是图片") + return + } + avatar, err = createAvatarImages(r.Context(), file) + if err != nil { + if errors.Is(err, errAvatarBusy) { + w.Header().Set("Retry-After", "2") + fail(w, http.StatusServiceUnavailable, 50001, err.Error()) + } else { + fail(w, http.StatusBadRequest, 20001, err.Error()) + } + return + } + contentType, extension, size = "image/jpeg", "-av1.jpg", int64(len(avatar.Display)) + } + + provider := a.configPlain(r.Context(), "storage.provider", "local") + name := fmt.Sprintf("%d-%d%s", current(r).ID, time.Now().UnixNano(), extension) + objectKey := name + if provider != "local" { + prefix := strings.Trim(a.configPlain(r.Context(), "storage.object_prefix", "media"), "/") + objectKey = fmt.Sprintf("%s/%s/%s", prefix, time.Now().Format("2006/01"), name) + } + mediaType := "audio" + if strings.HasPrefix(contentType, "image/") { + mediaType = "image" + } + + var bucket, publicURL string + var storage mediaObjectStorage + closeStorage := func() {} + if provider == "local" { + storage = localMediaStorage{directory: a.configPlain(r.Context(), "storage.local.directory", a.config.MediaDir)} + baseURL := strings.TrimSpace(a.configPlain(r.Context(), "storage.local.public_base_url", "")) + if baseURL == "" { + scheme := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]) + if scheme != "https" { + scheme = "http" + } + baseURL = fmt.Sprintf("%s://%s/uploads", scheme, r.Host) + } + publicURL = storagePublicURL(baseURL, objectKey) + } else { + storage, closeStorage, err = a.newMediaObjectStorage(r.Context()) + if err != nil { + log.Printf("storage provider init failed provider=%s error=%v", provider, err) + fail(w, http.StatusServiceUnavailable, 50001, "文件存储连接初始化失败") + return + } + defer closeStorage() + bucket = storage.Bucket() + publicURL = storagePublicURL(a.configPlain(r.Context(), "storage."+provider+".public_base_url", ""), objectKey) + } + if len(publicURL) > 500 || len(objectKey) > 500 { + fail(w, http.StatusServiceUnavailable, 50001, "文件存储公开地址过长") + return + } + objects := []mediaUploadObject{{Key: objectKey, ContentType: contentType, Size: size, Body: file}} + var thumbnailURL, originalURL string + if purpose == "avatar" { + stem := strings.TrimSuffix(objectKey, ".jpg") + urlStem := strings.TrimSuffix(publicURL, ".jpg") + thumbnailURL, originalURL = urlStem+"-thumb.jpg", urlStem+"-original"+originalExtension + if len(originalURL) > 500 || len(stem+"-original"+originalExtension) > 500 { + fail(w, http.StatusServiceUnavailable, 50001, "文件存储公开地址过长") + return + } + // Publish the primary URL only once its sibling variants are available. + objects = []mediaUploadObject{ + {Key: stem + "-original" + originalExtension, ContentType: originalType, Size: originalSize, Body: file}, + {Key: stem + "-thumb.jpg", ContentType: "image/jpeg", Size: int64(len(avatar.Thumbnail)), Body: bytes.NewReader(avatar.Thumbnail)}, + {Key: objectKey, ContentType: contentType, Size: size, Body: bytes.NewReader(avatar.Display)}, + } + } + + result, err := a.db.ExecContext(r.Context(), `INSERT INTO media_assets(owner_user_id,media_type,storage_provider,bucket,object_key,public_url,mime_type,file_size,moderation_status,status) VALUES(?,?,?,?,?,'',?,?,1,0)`, current(r).ID, mediaType, provider, bucket, objectKey, contentType, size) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建媒体记录失败") + return + } + mediaID, _ := result.LastInsertId() + cleanupRecord := func() { + ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second) + defer cancel() + _, _ = a.db.ExecContext(ctx, `DELETE FROM media_assets WHERE id=? AND status=0`, mediaID) + } + + cleanupObjects, err := putMediaObjects(r.Context(), storage, objects) + if err != nil { + cleanupRecord() + log.Printf("media upload failed provider=%s bucket=%s object=%s original=%q error=%v", provider, bucket, objectKey, filepath.Base(fileHeader.Filename), err) + fail(w, http.StatusBadGateway, 50001, "文件上传失败,请检查存储配置后重试") + return + } + if _, err = a.db.ExecContext(r.Context(), `UPDATE media_assets SET public_url=?,status=1 WHERE id=? AND status=0`, publicURL, mediaID); err != nil { + cleanupObjects() + cleanupRecord() + fail(w, http.StatusInternalServerError, 50001, "完成媒体记录失败") + return + } + reply(w, map[string]any{ + "id": mediaID, + "name": name, + "url": publicURL, + "provider": provider, + "objectKey": objectKey, + "thumbnailUrl": thumbnailURL, + "originalUrl": originalURL, + }) +} + +func (a *App) serveMedia(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + name := parts[len(parts)-1] + if !mediaNamePattern.MatchString(name) { + http.NotFound(w, r) + return + } + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Set("X-Content-Type-Options", "nosniff") + directory := a.configPlain(r.Context(), "storage.local.directory", a.config.MediaDir) + http.ServeFile(w, r, filepath.Join(directory, filepath.Base(name))) +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/media_batch.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/media_batch.go new file mode 100644 index 0000000..67bd0a2 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/media_batch.go @@ -0,0 +1,64 @@ +package app + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +type mediaUploadObject struct { + Key, ContentType string + Size int64 + Body io.Reader +} + +type localMediaStorage struct{ directory string } + +func (s localMediaStorage) Bucket() string { return "" } +func (s localMediaStorage) Put(_ context.Context, key, _ string, _ int64, body io.Reader) error { + if err := os.MkdirAll(s.directory, 0o755); err != nil { + return err + } + target := filepath.Join(s.directory, filepath.Base(key)) + file, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err != nil { + return err + } // Never remove an existing file on collision. + _, copyErr := io.Copy(file, body) + closeErr := file.Close() + if copyErr != nil || closeErr != nil { + _ = os.Remove(target) + if copyErr != nil { + return copyErr + } + return closeErr + } + return nil +} +func (s localMediaStorage) Delete(_ context.Context, key string) error { + return os.Remove(filepath.Join(s.directory, filepath.Base(key))) +} + +// Return a cleanup function for both a partially failed upload and a failed DB +// finalization. Cleanup gets its own deadline even if the HTTP client cancels. +func putMediaObjects(ctx context.Context, storage mediaObjectStorage, objects []mediaUploadObject) (func(), error) { + var uploaded []string + cleanup := func() { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second) + defer cancel() + for i := len(uploaded) - 1; i >= 0; i-- { + _ = storage.Delete(cleanupCtx, uploaded[i]) + } + } + for _, object := range objects { + if err := storage.Put(ctx, object.Key, object.ContentType, object.Size, object.Body); err != nil { + cleanup() + return func() {}, fmt.Errorf("store media object: %w", err) + } + uploaded = append(uploaded, object.Key) + } + return cleanup, nil +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/media_batch_test.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/media_batch_test.go new file mode 100644 index 0000000..8de3134 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/media_batch_test.go @@ -0,0 +1,84 @@ +package app + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "testing" +) + +type recordingMediaStorage struct { + files map[string][]byte + failAt string + cancel context.CancelFunc +} + +func (s *recordingMediaStorage) Bucket() string { return "test-bucket" } +func (s *recordingMediaStorage) Put(_ context.Context, key, _ string, size int64, body io.Reader) error { + if key == s.failAt { + if s.cancel != nil { + s.cancel() + } + return errors.New("storage unavailable") + } + data, err := io.ReadAll(body) + if err != nil { + return err + } + if int64(len(data)) != size { + return errors.New("incorrect content length") + } + s.files[key] = data + return nil +} +func (s *recordingMediaStorage) Delete(ctx context.Context, key string) error { + if err := ctx.Err(); err != nil { + return err + } + delete(s.files, key) + return nil +} +func TestMediaVariantUploadCleanup(t *testing.T) { + for _, failure := range []string{"", "thumb.jpg", "display.jpg"} { + t.Run(failure, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + storage := &recordingMediaStorage{files: map[string][]byte{}, failAt: failure, cancel: cancel} + var objects []mediaUploadObject + for _, key := range []string{"original.png", "thumb.jpg", "display.jpg"} { + objects = append(objects, mediaUploadObject{Key: key, ContentType: "image/jpeg", Size: 3, Body: bytes.NewReader([]byte("abc"))}) + } + cleanup, err := putMediaObjects(ctx, storage, objects) + if failure == "" { + if err != nil || len(storage.files) != 3 { + t.Fatal("missing uploaded variants", err) + } + cancel() + cleanup() + } else if err == nil { + t.Fatal("expected upload failure") + } + if len(storage.files) != 0 { + t.Fatal("partial objects left behind after failure/cancellation") + } + }) + } +} +func TestLocalUploadCollisionPreservesExistingObject(t *testing.T) { + directory := t.TempDir() + storage := localMediaStorage{directory: directory} + file := filepath.Join(directory, "1-1-av1.jpg") + if err := os.WriteFile(file, []byte("existing"), 0600); err != nil { + t.Fatal(err) + } + if _, err := putMediaObjects(context.Background(), storage, []mediaUploadObject{{Key: "1-1-av1.jpg", Body: bytes.NewReader([]byte("replacement"))}}); err == nil { + t.Fatal("collision should fail") + } + data, err := os.ReadFile(file) + if err != nil || string(data) != "existing" { + t.Fatal("existing object was removed or overwritten", err) + } +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/media_upload_test.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/media_upload_test.go new file mode 100644 index 0000000..f644ce2 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/media_upload_test.go @@ -0,0 +1,191 @@ +package app + +import ( + "bytes" + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "errors" + "image" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" +) + +type mediaUploadDB struct { + directory string + inserted, finalized, deleted, failFinalize bool + publicURL, mime string + size int64 +} +type mediaUploadConnector struct{ db *mediaUploadDB } + +func (c mediaUploadConnector) Connect(context.Context) (driver.Conn, error) { return c.db, nil } +func (c mediaUploadConnector) Driver() driver.Driver { return oauthTestDriver{} } +func (*mediaUploadDB) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("unexpected prepare") +} +func (*mediaUploadDB) Begin() (driver.Tx, error) { return nil, errors.New("unexpected transaction") } +func (*mediaUploadDB) Close() error { return nil } +func (s *mediaUploadDB) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { + if !strings.HasPrefix(query, "SELECT config_value,value_type FROM system_configs") { + return nil, errors.New("unexpected query") + } + values := map[string]string{"storage.provider": "local", "storage.object_prefix": "media", "storage.local.directory": s.directory, "storage.local.public_base_url": "https://media.example.com/uploads"} + value, ok := values[args[0].Value.(string)] + if !ok { + return &oauthTestRows{columns: []string{"config_value", "value_type"}}, nil + } + return oauthRow(value, "text"), nil +} +func (s *mediaUploadDB) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + switch { + case strings.HasPrefix(query, "INSERT INTO media_assets"): + s.inserted = true + s.mime = args[5].Value.(string) + s.size = args[6].Value.(int64) + return provisioningInsertResult{}, nil + case strings.HasPrefix(query, "UPDATE media_assets SET public_url="): + if s.failFinalize { + return nil, errors.New("finalization unavailable") + } + s.publicURL = args[0].Value.(string) + s.finalized = true + case strings.HasPrefix(query, "DELETE FROM media_assets"): + s.deleted = true + default: + return nil, errors.New("unexpected exec") + } + return driver.RowsAffected(1), nil +} + +func uploadTestApp(t *testing.T) (*App, *mediaUploadDB) { + t.Helper() + store := &mediaUploadDB{directory: t.TempDir()} + db := sql.OpenDB(mediaUploadConnector{store}) + t.Cleanup(func() { _ = db.Close() }) + return &App{db: db, config: Config{MediaDir: store.directory, Environment: "development"}}, store +} +func uploadTestRequest(t *testing.T, a *App, source []byte, purpose string) *httptest.ResponseRecorder { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", "camera.png") + if err != nil { + t.Fatal(err) + } + if _, err = part.Write(source); err != nil { + t.Fatal(err) + } + if purpose != "" { + if err = writer.WriteField("purpose", purpose); err != nil { + t.Fatal(err) + } + } + if err = writer.Close(); err != nil { + t.Fatal(err) + } + r := httptest.NewRequest(http.MethodPost, "/api/v1/media/upload", &body) + r.Header.Set("Content-Type", writer.FormDataContentType()) + r = r.WithContext(context.WithValue(r.Context(), identityKey{}, identity{ID: 42, Role: "user"})) + w := httptest.NewRecorder() + a.uploadMedia(w, r) + return w +} + +func TestAvatarUploadStoresAndServesAllVariants(t *testing.T) { + a, store := uploadTestApp(t) + source := avatarPNG(t, 1000, 800) + w := uploadTestRequest(t, a, source, "avatar") + if w.Code != 200 { + t.Fatalf("upload failed: %s", w.Body.String()) + } + var payload struct { + Data struct { + URL string `json:"url"` + ThumbnailURL string `json:"thumbnailUrl"` + OriginalURL string `json:"originalUrl"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + data := payload.Data + if !store.inserted || !store.finalized || store.publicURL != data.URL || store.mime != "image/jpeg" { + t.Fatal("media record does not identify the display image") + } + for _, variant := range []struct { + url string + max int + }{{data.URL, 640}, {data.ThumbnailURL, 256}, {data.OriginalURL, 0}} { + u, err := url.Parse(variant.url) + if err != nil || !mediaNamePattern.MatchString(filepath.Base(u.Path)) { + t.Fatal("invalid variant URL", variant.url) + } + content, err := os.ReadFile(filepath.Join(store.directory, filepath.Base(u.Path))) + if err != nil { + t.Fatal(err) + } + if variant.max == 0 { + if !bytes.Equal(content, source) { + t.Fatal("original upload was not preserved") + } + } else { + cfg, format, err := image.DecodeConfig(bytes.NewReader(content)) + if err != nil || format != "jpeg" || cfg.Width != variant.max || cfg.Height > variant.max { + t.Fatal("invalid variant dimensions", err) + } + if variant.url == data.URL && store.size != int64(len(content)) { + t.Fatal("DB file_size does not match uploaded JPEG") + } + } + response := httptest.NewRecorder() + a.serveMedia(response, httptest.NewRequest(http.MethodGet, u.Path, nil)) + if response.Code != 200 || !bytes.Equal(response.Body.Bytes(), content) || !strings.Contains(response.Header().Get("Cache-Control"), "immutable") { + t.Fatal("variant is not publicly cacheable") + } + } +} + +func TestAvatarUploadFinalizationFailureCleansEveryObject(t *testing.T) { + a, store := uploadTestApp(t) + store.failFinalize = true + w := uploadTestRequest(t, a, avatarPNG(t, 300, 200), "avatar") + files, err := os.ReadDir(store.directory) + if w.Code != 500 || !store.deleted || store.finalized || err != nil || len(files) != 0 { + t.Fatal("failed upload retained objects or an active record", w.Code, err, len(files)) + } +} + +func TestOrdinaryMediaUploadPreservesOriginal(t *testing.T) { + a, store := uploadTestApp(t) + source := avatarPNG(t, 800, 400) + w := uploadTestRequest(t, a, source, "") + if w.Code != 200 || store.mime != "image/png" || store.size != int64(len(source)) { + t.Fatal("ordinary image upload changed", w.Body.String()) + } + files, err := os.ReadDir(store.directory) + if err != nil || len(files) != 1 { + t.Fatal("unexpected variants on ordinary media") + } + data, err := os.ReadFile(filepath.Join(store.directory, files[0].Name())) + if err != nil || !bytes.Equal(data, source) { + t.Fatal("ordinary image was recompressed") + } +} + +func TestInvalidAvatarUploadCreatesNoRecords(t *testing.T) { + for _, purpose := range []string{"avatar", "unknown"} { + a, store := uploadTestApp(t) + w := uploadTestRequest(t, a, []byte("GIF89a\x01\x00\x01\x00broken"), purpose) + if w.Code != 400 || store.inserted { + t.Fatal("invalid upload created media records", w.Code) + } + } +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/membership.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/membership.go new file mode 100644 index 0000000..6dece38 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/membership.go @@ -0,0 +1,344 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +func (a *App) availablePaymentChannels(ctx context.Context) []map[string]any { + mode := a.configPlain(ctx, "payment.mode", "sandbox") + gatewayReady := mode == "sandbox" || a.paymentGatewayConfigured(ctx) + if a.config.Environment == "production" && mode == "sandbox" { + gatewayReady = false + } + channels := []map[string]any{} + if a.configBool(ctx, "payment.alipay.enabled", true) { + channels = append(channels, map[string]any{"code": "alipay", "name": "支付宝", "icon": "支", "configured": gatewayReady}) + } + if a.configBool(ctx, "payment.wechat.enabled", true) { + channels = append(channels, map[string]any{"code": "wechat", "name": "微信支付", "icon": "微", "configured": gatewayReady}) + } + return channels +} + +func (a *App) paymentChannels(w http.ResponseWriter, r *http.Request) { + reply(w, map[string]any{"mode": a.configPlain(r.Context(), "payment.mode", "sandbox"), "items": a.availablePaymentChannels(r.Context())}) +} + +type planView struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Level int `json:"level"` + DurationDays int `json:"durationDays"` + DailyActiveChatLimit int `json:"dailyActiveChatLimit"` + DailyLikeLimit int `json:"dailyLikeLimit"` + CanViewVisitors bool `json:"canViewVisitors"` + CanInvisibleVisit bool `json:"canInvisibleVisit"` + RecommendationWeight int `json:"recommendationWeight"` + PriceCent int `json:"priceCent"` + OriginalPriceCent int `json:"originalPriceCent"` + Status int `json:"status"` + SortOrder int `json:"sortOrder"` +} + +func (a *App) membershipPlans(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT id,code,name,level,duration_days,daily_active_chat_limit,daily_like_limit,can_view_visitors,can_invisible_visit,recommendation_weight,price_cent,original_price_cent,status,sort_order FROM membership_plans WHERE status=1 AND deleted_at IS NULL ORDER BY sort_order`) + if err != nil { + fail(w, 500, 50001, "查询套餐失败") + return + } + defer rows.Close() + items := []planView{} + for rows.Next() { + var item planView + _ = rows.Scan(&item.ID, &item.Code, &item.Name, &item.Level, &item.DurationDays, &item.DailyActiveChatLimit, &item.DailyLikeLimit, &item.CanViewVisitors, &item.CanInvisibleVisit, &item.RecommendationWeight, &item.PriceCent, &item.OriginalPriceCent, &item.Status, &item.SortOrder) + items = append(items, item) + } + reply(w, map[string]any{"items": items}) +} + +func (a *App) membershipStatus(w http.ResponseWriter, r *http.Request) { + quota := a.dailyActiveChatQuota(r.Context(), current(r).ID) + entitlements := a.resolveMembershipEntitlements(r.Context(), current(r).ID) + var likeUsed int + _ = a.db.QueryRowContext(r.Context(), `SELECT used_count FROM user_daily_like_usage WHERE user_id=? AND usage_date=CURRENT_DATE()`, current(r).ID).Scan(&likeUsed) + likeRemaining := -1 + if entitlements.DailyLikeLimit > 0 { + likeRemaining = entitlements.DailyLikeLimit - likeUsed + if likeRemaining < 0 { + likeRemaining = 0 + } + } + entitlementView := map[string]any{"dailyActiveChatLimit": entitlements.DailyActiveChatLimit, "dailyLikeLimit": entitlements.DailyLikeLimit, "dailyLikeUsed": likeUsed, "dailyLikeRemaining": likeRemaining, "canViewVisitors": entitlements.CanViewVisitors, "canInvisibleVisit": entitlements.CanInvisibleVisit, "recommendationWeight": entitlements.RecommendationWeight} + var planName string + var level int + var expires time.Time + err := a.db.QueryRowContext(r.Context(), `SELECT p.name,p.level,s.expires_at FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id WHERE s.user_id=? AND s.status=1 AND s.started_at<=NOW(3) AND s.expires_at>NOW(3) ORDER BY p.level DESC,s.expires_at DESC LIMIT 1`, current(r).ID).Scan(&planName, &level, &expires) + if err != nil { + reply(w, map[string]any{"active": false, "dailyActiveChat": quota, "entitlements": entitlementView, "level": 0, "name": "普通用户"}) + return + } + reply(w, map[string]any{"active": true, "dailyActiveChat": quota, "entitlements": entitlementView, "level": level, "name": planName, "expiresAt": expires}) +} + +func (a *App) createOrder(w http.ResponseWriter, r *http.Request) { + var req struct { + PlanID int64 `json:"planId"` + Channel string `json:"channel"` + } + if decode(r, &req) != nil || req.PlanID == 0 { + fail(w, 400, 20001, "请选择套餐") + return + } + channels := a.availablePaymentChannels(r.Context()) + if req.Channel == "" && len(channels) > 0 { + req.Channel, _ = channels[0]["code"].(string) + } + channelAllowed := false + for _, channel := range channels { + if channel["code"] == req.Channel { + channelAllowed = channel["configured"] == true + } + } + if !channelAllowed { + fail(w, 400, 20001, "支付渠道未启用或配置不完整") + return + } + var price int + if a.db.QueryRowContext(r.Context(), `SELECT price_cent FROM membership_plans WHERE id=? AND status=1 AND deleted_at IS NULL`, req.PlanID).Scan(&price) != nil { + fail(w, 404, 30001, "套餐不存在") + return + } + orderNo := fmt.Sprintf("XY%d%d%s", time.Now().UnixMilli(), current(r).ID, randomToken()[:8]) + result, err := a.db.ExecContext(r.Context(), `INSERT INTO orders(order_no,user_id,product_type,product_id,amount_cent,status,channel)VALUES(?,?,'membership',?,?,'CREATED',?)`, orderNo, current(r).ID, req.PlanID, price, req.Channel) + if err != nil { + fail(w, 500, 50001, "创建订单失败") + return + } + id, _ := result.LastInsertId() + reply(w, map[string]any{"id": id, "orderNo": orderNo, "amountCent": price, "status": "CREATED", "channel": req.Channel}) +} + +func (a *App) payOrder(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "订单编号无效") + return + } + mode := a.configPlain(r.Context(), "payment.mode", "sandbox") + if mode != "sandbox" && mode != "live" { + fail(w, http.StatusServiceUnavailable, 50003, "支付模式配置无效") + return + } + if mode == "live" { + var orderNo, status, channel, subject, providerOrderNo, checkoutURL string + var amountCent int + var paymentPayload sql.NullString + err = a.db.QueryRowContext(r.Context(), `SELECT o.order_no,o.amount_cent,o.status,o.channel,COALESCE(p.name,'会员套餐'),o.provider_order_no,o.checkout_url,o.payment_payload FROM orders o LEFT JOIN membership_plans p ON p.id=o.product_id WHERE o.id=? AND o.user_id=? AND o.deleted_at IS NULL`, id, current(r).ID).Scan(&orderNo, &amountCent, &status, &channel, &subject, &providerOrderNo, &checkoutURL, &paymentPayload) + if err != nil { + fail(w, http.StatusNotFound, 30001, "订单不存在") + return + } + if status == "PAID" { + reply(w, map[string]any{"success": true, "status": "PAID", "mode": mode}) + return + } + if status != "CREATED" { + fail(w, http.StatusBadRequest, 20001, "当前订单状态无法支付") + return + } + if providerOrderNo != "" && (checkoutURL != "" || paymentPayload.Valid) { + var appPayload map[string]any + if paymentPayload.Valid && paymentPayload.String != "" { + _ = json.Unmarshal([]byte(paymentPayload.String), &appPayload) + } + reply(w, map[string]any{"success": true, "mode": mode, "status": status, "providerOrderNo": providerOrderNo, "checkoutUrl": checkoutURL, "appPayload": appPayload}) + return + } + gatewayResult, gatewayErr := a.createGatewayPayment(r.Context(), paymentGatewayOrder{OrderNo: orderNo, AmountCent: amountCent, Channel: channel, Subject: subject, UserID: current(r).ID}) + if gatewayErr != nil { + fail(w, http.StatusBadGateway, 50003, "支付网关下单失败") + return + } + payloadJSON := "" + if len(gatewayResult.AppPayload) > 0 { + encoded, _ := json.Marshal(gatewayResult.AppPayload) + payloadJSON = string(encoded) + } + if _, err = a.db.ExecContext(r.Context(), `UPDATE orders SET provider_order_no=?,checkout_url=?,payment_payload=? WHERE id=? AND user_id=? AND status='CREATED' AND provider_order_no=''`, gatewayResult.ProviderOrderNo, gatewayResult.CheckoutURL, payloadJSON, id, current(r).ID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存支付信息失败") + return + } + reply(w, map[string]any{"success": true, "mode": mode, "status": status, "providerOrderNo": gatewayResult.ProviderOrderNo, "checkoutUrl": gatewayResult.CheckoutURL, "appPayload": gatewayResult.AppPayload}) + return + } + if a.config.Environment == "production" { + fail(w, http.StatusServiceUnavailable, 50003, "生产环境禁止沙箱支付") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "支付失败") + return + } + defer func() { _ = tx.Rollback() }() + var userID, planID int64 + var amountCent int + var orderNo string + var status string + err = tx.QueryRowContext(r.Context(), `SELECT user_id,product_id,amount_cent,order_no,status FROM orders WHERE id=? AND user_id=? AND deleted_at IS NULL FOR UPDATE`, id, current(r).ID).Scan(&userID, &planID, &amountCent, &orderNo, &status) + if err != nil { + fail(w, 404, 30001, "订单不存在") + return + } + if status == "PAID" { + reply(w, map[string]any{"success": true, "status": "PAID"}) + return + } + if status != "CREATED" { + fail(w, 400, 20001, "当前订单状态无法支付") + return + } + var durationDays, level int + if err = tx.QueryRowContext(r.Context(), `SELECT duration_days,level FROM membership_plans WHERE id=? AND status=1 AND deleted_at IS NULL`, planID).Scan(&durationDays, &level); err != nil { + fail(w, 400, 20001, "会员套餐已下架") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='PAID',paid_at=NOW(3),paid_amount_cent=?,provider_order_no=?,payment_notified_at=NOW(3) WHERE id=?`, amountCent, "sandbox:"+orderNo, id); err == nil { + err = grantOrderMembershipTx(r.Context(), tx, id, userID, planID, durationDays, level) + } + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "支付入账失败") + return + } + reply(w, map[string]any{"success": true, "status": "PAID", "mode": mode}) +} + +func (a *App) orderStatus(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "订单编号无效") + return + } + var orderNo, status, channel, productName, refundReason string + var amountCent int + var paidAt, refundRequestedAt sql.NullTime + if err = a.db.QueryRowContext(r.Context(), `SELECT o.order_no,o.amount_cent,o.status,o.channel,o.paid_at,COALESCE(p.name,'已删除套餐'),o.refund_reason,o.refund_requested_at FROM orders o LEFT JOIN membership_plans p ON p.id=o.product_id WHERE o.id=? AND o.user_id=? AND o.deleted_at IS NULL`, id, current(r).ID).Scan(&orderNo, &amountCent, &status, &channel, &paidAt, &productName, &refundReason, &refundRequestedAt); err != nil { + fail(w, http.StatusNotFound, 30001, "订单不存在") + return + } + result := map[string]any{"id": id, "orderNo": orderNo, "amountCent": amountCent, "status": status, "channel": channel, "productName": productName, "refundReason": refundReason, "refundRequestedAt": nullableTime(refundRequestedAt)} + if paidAt.Valid { + result["paidAt"] = paidAt.Time + } + reply(w, result) +} + +func (a *App) myOrders(w http.ResponseWriter, r *http.Request) { + page, pageSize, offset := pageOptions(r) + var total int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM orders WHERE user_id=? AND deleted_at IS NULL`, current(r).ID).Scan(&total); err != nil { + fail(w, 500, 50001, "查询失败") + return + } + rows, err := a.db.QueryContext(r.Context(), `SELECT o.id,o.order_no,o.amount_cent,o.status,o.channel,o.created_at,COALESCE(p.name,'已删除套餐') FROM orders o LEFT JOIN membership_plans p ON p.id=o.product_id WHERE o.user_id=? AND o.deleted_at IS NULL ORDER BY o.created_at DESC LIMIT ? OFFSET ?`, current(r).ID, pageSize, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var orderNo, status, channel, name string + var amount int + var created time.Time + _ = rows.Scan(&id, &orderNo, &amount, &status, &channel, &created, &name) + items = append(items, map[string]any{"id": id, "orderNo": orderNo, "amountCent": amount, "status": status, "channel": channel, "createdAt": created, "productName": name}) + } + reply(w, map[string]any{"items": items, "page": page, "pageSize": pageSize, "total": total, "hasMore": offset+len(items) < total}) +} + +func (a *App) notifications(w http.ResponseWriter, r *http.Request) { + page, pageSize, offset := pageOptions(r) + notificationType := strings.TrimSpace(r.URL.Query().Get("type")) + where := ` WHERE user_id=?` + args := []any{current(r).ID} + if notificationType != "" { + where += ` AND type=?` + args = append(args, notificationType) + } + args = append(args, pageSize, offset) + rows, err := a.db.QueryContext(r.Context(), `SELECT id,type,title,content,biz_type,biz_id,read_at,created_at FROM notifications`+where+` ORDER BY created_at DESC LIMIT ? OFFSET ?`, args...) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id int64 + var typ, title, content, bizType string + var bizID any + var readAt any + var created time.Time + _ = rows.Scan(&id, &typ, &title, &content, &bizType, &bizID, &readAt, &created) + items = append(items, map[string]any{"id": id, "type": typ, "title": title, "content": content, "bizType": bizType, "bizId": bizID, "readAt": readAt, "createdAt": created}) + } + var unread, total int + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM notifications WHERE user_id=? AND read_at IS NULL`, current(r).ID).Scan(&unread) + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM notifications`+where, args[:len(args)-2]...).Scan(&total) + rowsByType, _ := a.db.QueryContext(r.Context(), `SELECT type,COUNT(*) FROM notifications WHERE user_id=? AND read_at IS NULL GROUP BY type`, current(r).ID) + unreadByType := map[string]int{} + if rowsByType != nil { + defer rowsByType.Close() + for rowsByType.Next() { + var typ string + var count int + _ = rowsByType.Scan(&typ, &count) + unreadByType[typ] = count + } + } + reply(w, map[string]any{"items": items, "page": page, "pageSize": pageSize, "total": total, "unread": unread, "unreadByType": unreadByType, "hasMore": offset+len(items) < total}) +} +func (a *App) readAllNotifications(w http.ResponseWriter, r *http.Request) { + _, _ = a.db.ExecContext(r.Context(), `UPDATE notifications SET read_at=NOW(3) WHERE user_id=? AND read_at IS NULL`, current(r).ID) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) appConfig(w http.ResponseWriter, r *http.Request) { + rows, _ := a.db.QueryContext(r.Context(), `SELECT config_key,config_value,value_type FROM system_configs WHERE value_type<>'secret' AND config_key LIKE 'app.%'`) + configs := map[string]any{} + if rows != nil { + defer rows.Close() + for rows.Next() { + var key, value, typ string + _ = rows.Scan(&key, &value, &typ) + configs[key] = value + } + } + platform := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("platform"))) + if platform != "android" && platform != "ios" && platform != "h5" { + platform = "h5" + } + latest := map[string]any{} + var version, downloadURL, notes string + var build int + var force bool + if a.db.QueryRowContext(r.Context(), `SELECT version,build_number,force_update,download_url,release_notes FROM app_versions WHERE platform=? AND status=1 ORDER BY build_number DESC LIMIT 1`, platform).Scan(&version, &build, &force, &downloadURL, ¬es) == nil { + latest = map[string]any{"version": version, "buildNumber": build, "forceUpdate": force, "downloadUrl": downloadURL, "releaseNotes": notes} + } + features := map[string]bool{ + "nearby": a.configBool(r.Context(), "app.features.nearby", true), + "feed": a.configBool(r.Context(), "app.features.feed", true), + "membership": a.configBool(r.Context(), "app.features.membership", true), + "im": a.configBool(r.Context(), "app.features.im", true), + } + reply(w, map[string]any{"configs": configs, "platform": platform, "features": features, "maintenance": map[string]any{"enabled": a.configBool(r.Context(), "app.maintenance.enabled", false), "message": a.configPlain(r.Context(), "app.maintenance.message", "系统维护中,请稍后再试")}, "legal": map[string]string{"userAgreementVersion": a.configPlain(r.Context(), "legal.user_agreement_version", "1.0"), "privacyPolicyVersion": a.configPlain(r.Context(), "legal.privacy_policy_version", "1.0"), "operatorName": a.configPlain(r.Context(), "legal.operator_name", ""), "contact": a.configPlain(r.Context(), "legal.contact", ""), "effectiveDate": a.configPlain(r.Context(), "legal.effective_date", ""), "userAgreementUrl": a.configPlain(r.Context(), "legal.user_agreement_url", ""), "privacyPolicyUrl": a.configPlain(r.Context(), "legal.privacy_policy_url", "")}, "minVersion": a.configPlain(r.Context(), "app.min_version."+platform, "1.0.0"), "latest": latest}) +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/membership_limits.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/membership_limits.go new file mode 100644 index 0000000..16e5323 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/membership_limits.go @@ -0,0 +1,122 @@ +package app + +import ( + "context" + "database/sql" + "fmt" + "strconv" +) + +const defaultFreeDailyActiveChatLimit = 5 + +type rowQuerier interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +type dailyActiveChatLimitError struct { + Limit int +} + +func (e *dailyActiveChatLimitError) Error() string { + return fmt.Sprintf("今日主动聊天人数已达上限(%d人),回复收到的消息不受此限制", e.Limit) +} + +func (a *App) resolveDailyActiveChatLimit(ctx context.Context, queryer rowQuerier, userID int64) int { + var limit int + err := queryer.QueryRowContext(ctx, `SELECT p.daily_active_chat_limit + FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id + WHERE s.user_id=? AND s.status=1 AND s.started_at<=NOW(3) AND s.expires_at>NOW(3) + ORDER BY p.level DESC,s.expires_at DESC LIMIT 1`, userID).Scan(&limit) + if err == nil { + return limit + } + + var raw string + if err = queryer.QueryRowContext(ctx, `SELECT config_value FROM system_configs WHERE config_key='membership.free_daily_active_chat_limit'`).Scan(&raw); err == nil { + if parsed, parseErr := strconv.Atoi(raw); parseErr == nil && parsed >= 0 { + return parsed + } + } + return defaultFreeDailyActiveChatLimit +} + +func dailyActiveChatQuotaView(limit, used int) map[string]any { + remaining := -1 + unlimited := limit == 0 + if !unlimited { + remaining = limit - used + if remaining < 0 { + remaining = 0 + } + } + return map[string]any{ + "limit": limit, + "remaining": remaining, + "unlimited": unlimited, + "used": used, + } +} + +func (a *App) dailyActiveChatQuota(ctx context.Context, userID int64) map[string]any { + limit := a.resolveDailyActiveChatLimit(ctx, a.db, userID) + var used int + _ = a.db.QueryRowContext(ctx, `SELECT used_count FROM im_daily_active_chat_usage WHERE user_id=? AND usage_date=CURRENT_DATE()`, userID).Scan(&used) + return dailyActiveChatQuotaView(limit, used) +} + +func (a *App) reserveDailyActiveChat(ctx context.Context, tx *sql.Tx, conversationID, senderID int64) error { + var user1ID, user2ID int64 + err := tx.QueryRowContext(ctx, `SELECT user1_id,user2_id FROM im_direct_conversations WHERE conversation_id=?`, conversationID).Scan(&user1ID, &user2ID) + if err == sql.ErrNoRows { + return nil + } + if err != nil { + return err + } + targetUserID := user1ID + if senderID == user1ID { + targetUserID = user2ID + } else if senderID != user2ID { + return fmt.Errorf("不是会话成员") + } + + var inboundToday int + if err = tx.QueryRowContext(ctx, `SELECT EXISTS( + SELECT 1 FROM im_messages + WHERE conversation_id=? AND sender_id=? + AND created_at>=CURRENT_DATE() AND created_at 0 && used >= limit { + return &dailyActiveChatLimitError{Limit: limit} + } + if _, err = tx.ExecContext(ctx, `INSERT INTO im_daily_active_chat_targets(user_id,target_user_id,usage_date,conversation_id) VALUES(?,?,CURRENT_DATE(),?)`, senderID, targetUserID, conversationID); err != nil { + return err + } + _, err = tx.ExecContext(ctx, `UPDATE im_daily_active_chat_usage SET used_count=used_count+1 WHERE user_id=? AND usage_date=CURRENT_DATE()`, senderID) + return err +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/membership_limits_test.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/membership_limits_test.go new file mode 100644 index 0000000..0afd489 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/membership_limits_test.go @@ -0,0 +1,25 @@ +package app + +import "testing" + +func TestDailyActiveChatQuotaView(t *testing.T) { + limited := dailyActiveChatQuotaView(20, 7) + if limited["remaining"] != 13 || limited["unlimited"] != false { + t.Fatalf("unexpected limited quota: %#v", limited) + } + exhausted := dailyActiveChatQuotaView(5, 8) + if exhausted["remaining"] != 0 { + t.Fatalf("remaining quota must not be negative: %#v", exhausted) + } + unlimited := dailyActiveChatQuotaView(0, 99) + if unlimited["remaining"] != -1 || unlimited["unlimited"] != true { + t.Fatalf("unexpected unlimited quota: %#v", unlimited) + } +} + +func TestDailyActiveChatLimitError(t *testing.T) { + err := (&dailyActiveChatLimitError{Limit: 20}).Error() + if err != "今日主动聊天人数已达上限(20人),回复收到的消息不受此限制" { + t.Fatalf("unexpected quota message: %s", err) + } +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/password_change_test.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/password_change_test.go new file mode 100644 index 0000000..a7a4197 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/password_change_test.go @@ -0,0 +1,92 @@ +package app + +import ( + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type passwordChangeDB struct { + oauthTestDB + hash, pending string + revoked, pendingRevoked bool + failedWrite bool +} +type passwordChangeConnector struct{ db *passwordChangeDB } + +func (c passwordChangeConnector) Connect(context.Context) (driver.Conn, error) { return c.db, nil } +func (passwordChangeConnector) Driver() driver.Driver { return oauthTestDriver{} } +func (s *passwordChangeDB) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) { + s.pending, s.pendingRevoked = s.hash, s.revoked + return s, nil +} +func (s *passwordChangeDB) Commit() error { + s.hash, s.revoked = s.pending, s.pendingRevoked + return nil +} +func (s *passwordChangeDB) Rollback() error { s.pending = ""; return nil } +func (s *passwordChangeDB) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { + if strings.Contains(query, "SELECT password_hash FROM admin_users") { + return oauthRow(s.hash), nil + } + return s.oauthTestDB.QueryContext(ctx, query, args) +} +func (s *passwordChangeDB) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) { + switch { + case strings.HasPrefix(query, "UPDATE admin_users SET password_hash="): + s.pending = args[0].Value.(string) + case strings.HasPrefix(query, "UPDATE admin_sessions SET revoked_at="): + if s.failedWrite { + return nil, fmt.Errorf("session write failed") + } + s.pendingRevoked = true + case strings.HasPrefix(query, "INSERT INTO admin_audit_logs"): + default: + return s.oauthTestDB.ExecContext(ctx, query, args) + } + return driver.RowsAffected(1), nil +} + +func TestAdminPasswordChangeWithoutStrengthRules(t *testing.T) { + for _, password := range []string{"1", "lowercase", "中文", "OldPassword123!", strings.Repeat("长", 80)} { + t.Run(fmt.Sprintf("bytes-%d", len(password)), func(t *testing.T) { + original, err := hashPassword("OldPassword123!") + if err != nil { + t.Fatal(err) + } + store := &passwordChangeDB{hash: original} + db := sql.OpenDB(passwordChangeConnector{store}) + t.Cleanup(func() { _ = db.Close() }) + a := &App{db: db} + request := func(current, next string) *httptest.ResponseRecorder { + payload, _ := json.Marshal(map[string]string{"currentPassword": current, "newPassword": next}) + r := httptest.NewRequest(http.MethodPut, "/admin/v1/me/password", strings.NewReader(string(payload))) + r = r.WithContext(context.WithValue(r.Context(), identityKey{}, identity{ID: 7, Role: "admin"})) + w := httptest.NewRecorder() + a.adminChangePassword(w, r) + return w + } + if w := request("wrong", password); w.Code != http.StatusBadRequest || store.hash != original { + t.Fatal("incorrect old password accepted") + } + if w := request("OldPassword123!", ""); w.Code != http.StatusBadRequest { + t.Fatal("empty new password accepted") + } + store.failedWrite = true + if w := request("OldPassword123!", password); w.Code != http.StatusInternalServerError || store.hash != original { + t.Fatal("failed session revocation did not roll back the password") + } + store.failedWrite = false + w := request("OldPassword123!", password) + if w.Code != http.StatusOK || !store.revoked || !checkPassword(store.hash, password) { + t.Fatalf("password change failed: %d %s", w.Code, w.Body.String()) + } + }) + } +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/payment_gateway.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/payment_gateway.go new file mode 100644 index 0000000..0abef90 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/payment_gateway.go @@ -0,0 +1,347 @@ +package app + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type paymentGatewayOrder struct { + OrderNo string + AmountCent int + Channel string + Subject string + UserID int64 +} + +type paymentGatewayResult struct { + ProviderOrderNo string `json:"providerOrderNo"` + CheckoutURL string `json:"checkoutUrl"` + AppPayload map[string]any `json:"appPayload"` +} + +type paymentNotifyRequest struct { + EventID string `json:"eventId"` + OrderNo string `json:"orderNo"` + Channel string `json:"channel"` + ProviderOrderNo string `json:"providerOrderNo"` + Status string `json:"status"` + AmountCent int `json:"amountCent"` +} + +func validHTTPSURL(raw string) bool { + parsed, err := url.Parse(strings.TrimSpace(raw)) + return err == nil && parsed.Scheme == "https" && parsed.Host != "" +} + +func (a *App) paymentGatewayConfigured(ctx context.Context) bool { + createURL := a.configPlain(ctx, "payment.gateway.create_url", "") + refundURL := a.configPlain(ctx, "payment.gateway.refund_url", "") + notifyURL := a.configPlain(ctx, "payment.gateway.notify_url", "") + secret := a.configPlain(ctx, "payment.gateway.notify_secret", "") + token := a.configPlain(ctx, "payment.gateway.token", "") + if createURL == "" || refundURL == "" || notifyURL == "" || len(secret) < 32 || token == "" { + return false + } + if a.config.Environment == "production" && (!validHTTPSURL(createURL) || !validHTTPSURL(refundURL) || !validHTTPSURL(notifyURL)) { + return false + } + return true +} + +func (a *App) createGatewayPayment(ctx context.Context, order paymentGatewayOrder) (paymentGatewayResult, error) { + if !a.paymentGatewayConfigured(ctx) { + return paymentGatewayResult{}, fmt.Errorf("payment gateway is not completely configured") + } + payload, err := json.Marshal(map[string]any{ + "orderNo": order.OrderNo, "amountCent": order.AmountCent, "currency": "CNY", + "channel": order.Channel, "subject": order.Subject, "userId": order.UserID, + "notifyUrl": a.configPlain(ctx, "payment.gateway.notify_url", ""), + "returnUrl": a.configPlain(ctx, "payment.gateway.return_url", ""), + }) + if err != nil { + return paymentGatewayResult{}, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.configPlain(ctx, "payment.gateway.create_url", ""), bytes.NewReader(payload)) + if err != nil { + return paymentGatewayResult{}, err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", "Bearer "+a.configPlain(ctx, "payment.gateway.token", "")) + request.Header.Set("Idempotency-Key", order.OrderNo) + + timeout, _ := strconv.Atoi(a.configPlain(ctx, "payment.gateway.timeout_seconds", "10")) + if timeout < 3 || timeout > 30 { + timeout = 10 + } + response, err := (&http.Client{Timeout: time.Duration(timeout) * time.Second}).Do(request) + if err != nil { + return paymentGatewayResult{}, fmt.Errorf("payment gateway request failed: %w", err) + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, 512<<10)) + if err != nil { + return paymentGatewayResult{}, fmt.Errorf("read payment gateway response: %w", err) + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return paymentGatewayResult{}, fmt.Errorf("payment gateway returned HTTP %d", response.StatusCode) + } + + var envelope struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + } + var result paymentGatewayResult + if err = json.Unmarshal(body, &envelope); err == nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" { + if envelope.Code != 0 { + return paymentGatewayResult{}, fmt.Errorf("payment gateway rejected request: %s", envelope.Message) + } + err = json.Unmarshal(envelope.Data, &result) + } else { + err = json.Unmarshal(body, &result) + } + if err != nil { + return paymentGatewayResult{}, fmt.Errorf("invalid payment gateway response: %w", err) + } + if strings.TrimSpace(result.ProviderOrderNo) == "" || (result.CheckoutURL == "" && len(result.AppPayload) == 0) { + return paymentGatewayResult{}, fmt.Errorf("payment gateway response is incomplete") + } + if result.CheckoutURL != "" && a.config.Environment == "production" && !validHTTPSURL(result.CheckoutURL) { + return paymentGatewayResult{}, fmt.Errorf("payment gateway returned a non-HTTPS checkout URL") + } + return result, nil +} + +func (a *App) paymentNotify(w http.ResponseWriter, r *http.Request) { + if !a.paymentGatewayConfigured(r.Context()) { + fail(w, http.StatusServiceUnavailable, 50003, "支付网关未配置") + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 128<<10)) + if err != nil || len(body) == 0 { + fail(w, http.StatusBadRequest, 20001, "支付通知内容无效") + return + } + timestamp := strings.TrimSpace(r.Header.Get("X-Xingyu-Timestamp")) + signature := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("X-Xingyu-Signature"), "sha256=")) + unixSeconds, parseErr := strconv.ParseInt(timestamp, 10, 64) + if parseErr != nil || time.Since(time.Unix(unixSeconds, 0)) > 5*time.Minute || time.Until(time.Unix(unixSeconds, 0)) > 5*time.Minute { + fail(w, http.StatusUnauthorized, 10006, "支付通知时间戳无效") + return + } + mac := hmac.New(sha256.New, []byte(a.configPlain(r.Context(), "payment.gateway.notify_secret", ""))) + _, _ = mac.Write([]byte(timestamp + ".")) + _, _ = mac.Write(body) + expected := hex.EncodeToString(mac.Sum(nil)) + if len(signature) != len(expected) || subtle.ConstantTimeCompare([]byte(strings.ToLower(signature)), []byte(expected)) != 1 { + fail(w, http.StatusUnauthorized, 10006, "支付通知签名无效") + return + } + var notice paymentNotifyRequest + if json.Unmarshal(body, ¬ice) != nil || notice.EventID == "" || notice.OrderNo == "" || notice.Channel == "" || notice.ProviderOrderNo == "" || notice.AmountCent <= 0 { + fail(w, http.StatusBadRequest, 20001, "支付通知字段不完整") + return + } + notice.Status = strings.ToUpper(strings.TrimSpace(notice.Status)) + if notice.Status != "PAID" && notice.Status != "FAILED" && notice.Status != "CLOSED" && notice.Status != "REFUNDED" && notice.Status != "REFUND_FAILED" { + fail(w, http.StatusBadRequest, 20001, "支付通知状态无效") + return + } + if err = a.settlePayment(r.Context(), notice, string(body)); err != nil { + fail(w, http.StatusConflict, 20001, err.Error()) + return + } + reply(w, map[string]any{"success": true, "eventId": notice.EventID}) +} + +func (a *App) settlePayment(ctx context.Context, notice paymentNotifyRequest, raw string) error { + tx, err := a.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(ctx, `INSERT IGNORE INTO payment_events(event_id,order_no,channel,provider_order_no,event_status,amount_cent,raw_payload) VALUES(?,?,?,?,?,?,?)`, notice.EventID, notice.OrderNo, notice.Channel, notice.ProviderOrderNo, notice.Status, notice.AmountCent, raw) + if err != nil { + return err + } + affected, _ := result.RowsAffected() + if affected == 0 { + return tx.Commit() + } + + var orderID, userID, planID int64 + var amountCent int + var status, channel string + if err = tx.QueryRowContext(ctx, `SELECT id,user_id,product_id,amount_cent,status,channel FROM orders WHERE order_no=? AND deleted_at IS NULL FOR UPDATE`, notice.OrderNo).Scan(&orderID, &userID, &planID, &amountCent, &status, &channel); err != nil { + return fmt.Errorf("order does not exist") + } + if channel != notice.Channel || amountCent != notice.AmountCent { + return fmt.Errorf("payment amount or channel does not match the order") + } + if notice.Status == "REFUNDED" { + if status == "REFUNDED" { + return tx.Commit() + } + if status != "PAID" && status != "REFUNDING" { + return fmt.Errorf("order status does not allow refund") + } + if _, err = tx.ExecContext(ctx, `UPDATE orders SET status='REFUNDED',payment_notified_at=NOW(3) WHERE id=?`, orderID); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1 AND source IN (?,?)`, userID, fmt.Sprintf("order:%d", orderID), fmt.Sprintf("admin_order:%d", orderID)); err != nil { + return err + } + if err = a.recomputeMembershipTx(ctx, tx, userID); err != nil { + return err + } + return tx.Commit() + } + if notice.Status == "REFUND_FAILED" { + if status == "REFUNDING" { + if _, err = tx.ExecContext(ctx, `UPDATE orders SET status='PAID',payment_notified_at=NOW(3) WHERE id=?`, orderID); err != nil { + return err + } + } + return tx.Commit() + } + if notice.Status == "PAID" && (status == "PAID" || status == "REFUNDING" || status == "REFUNDED") { + return tx.Commit() + } + if status != "CREATED" { + return fmt.Errorf("order status does not allow payment") + } + if notice.Status != "PAID" { + return tx.Commit() + } + + var durationDays, level int + if err = tx.QueryRowContext(ctx, `SELECT duration_days,level FROM membership_plans WHERE id=? AND deleted_at IS NULL`, planID).Scan(&durationDays, &level); err != nil { + return fmt.Errorf("membership plan does not exist") + } + if _, err = tx.ExecContext(ctx, `UPDATE orders SET status='PAID',paid_at=NOW(3),paid_amount_cent=?,provider_order_no=?,payment_notified_at=NOW(3) WHERE id=?`, notice.AmountCent, notice.ProviderOrderNo, orderID); err != nil { + return err + } + if err = grantOrderMembershipTx(ctx, tx, orderID, userID, planID, durationDays, level); err != nil { + return err + } + return tx.Commit() +} + +func (a *App) createGatewayRefund(ctx context.Context, orderNo, providerOrderNo string, amountCent int) error { + endpoint := a.configPlain(ctx, "payment.gateway.refund_url", "") + if endpoint == "" || (a.config.Environment == "production" && !validHTTPSURL(endpoint)) { + return fmt.Errorf("payment refund gateway is not configured") + } + payload, err := json.Marshal(map[string]any{ + "orderNo": orderNo, "providerOrderNo": providerOrderNo, "amountCent": amountCent, + "currency": "CNY", "reason": "admin_requested", + }) + if err != nil { + return err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", "Bearer "+a.configPlain(ctx, "payment.gateway.token", "")) + request.Header.Set("Idempotency-Key", "refund:"+orderNo) + timeout, _ := strconv.Atoi(a.configPlain(ctx, "payment.gateway.timeout_seconds", "10")) + if timeout < 3 || timeout > 30 { + timeout = 10 + } + response, err := (&http.Client{Timeout: time.Duration(timeout) * time.Second}).Do(request) + if err != nil { + return fmt.Errorf("refund gateway request failed: %w", err) + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, 256<<10)) + if err != nil { + return err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("refund gateway returned HTTP %d", response.StatusCode) + } + var gatewayResponse struct { + Code *int `json:"code"` + Success *bool `json:"success"` + Message string `json:"message"` + } + if len(bytes.TrimSpace(body)) > 0 { + if err = json.Unmarshal(body, &gatewayResponse); err != nil { + return fmt.Errorf("refund gateway returned invalid JSON: %w", err) + } + if (gatewayResponse.Code != nil && *gatewayResponse.Code != 0) || (gatewayResponse.Success != nil && !*gatewayResponse.Success) { + return fmt.Errorf("refund gateway rejected request: %s", gatewayResponse.Message) + } + } + return nil +} + +func (a *App) requestLiveRefund(w http.ResponseWriter, r *http.Request, orderID int64) { + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建退款申请失败") + return + } + defer func() { _ = tx.Rollback() }() + var userID int64 + var amountCent int + var orderNo, providerOrderNo, status string + if err = tx.QueryRowContext(r.Context(), `SELECT user_id,amount_cent,order_no,provider_order_no,status FROM orders WHERE id=? AND deleted_at IS NULL FOR UPDATE`, orderID).Scan(&userID, &amountCent, &orderNo, &providerOrderNo, &status); err != nil { + fail(w, http.StatusNotFound, 30001, "订单不存在") + return + } + if status != "PAID" && status != "REFUND_REQUESTED" && status != "REFUNDING" { + fail(w, http.StatusBadRequest, 20001, "只有已支付、用户已申请退款或退款处理中的订单可发起退款") + return + } + if providerOrderNo == "" { + fail(w, http.StatusBadRequest, 20001, "订单缺少支付渠道流水号,不能自动退款") + return + } + if status == "PAID" || status == "REFUND_REQUESTED" { + if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='REFUNDING' WHERE id=?`, orderID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新退款状态失败") + return + } + } + if err = tx.Commit(); err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建退款申请失败") + return + } + if err = a.createGatewayRefund(r.Context(), orderNo, providerOrderNo, amountCent); err != nil { + a.audit(r, "refund_request_failed", "order", orderID, map[string]any{"userId": userID, "error": err.Error()}) + fail(w, http.StatusBadGateway, 50003, "退款网关请求失败,订单已保留为退款处理中,可安全重试") + return + } + a.audit(r, "refund_requested", "order", orderID, map[string]any{"userId": userID, "amountCent": amountCent}) + reply(w, map[string]any{"success": true, "status": "REFUNDING"}) +} + +func grantOrderMembershipTx(ctx context.Context, tx *sql.Tx, orderID, userID, planID int64, durationDays, level int) error { + var base time.Time + if err := tx.QueryRowContext(ctx, `SELECT GREATEST(NOW(3),COALESCE(MAX(expires_at),NOW(3))) FROM subscriptions WHERE user_id=? AND status=1 AND expires_at>NOW(3)`, userID).Scan(&base); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(?,?,?,1,NOW(3),DATE_ADD(?,INTERVAL ? DAY))`, userID, planID, fmt.Sprintf("order:%d", orderID), base, durationDays); err != nil { + return err + } + _, err := tx.ExecContext(ctx, `UPDATE user_profiles SET is_vip=1,vip_level=GREATEST(vip_level,?) WHERE user_id=?`, level, userID) + return err +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/public_auth.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/public_auth.go new file mode 100644 index 0000000..22c0321 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/public_auth.go @@ -0,0 +1,528 @@ +package app + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/binary" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type authRequest struct { + Phone string `json:"phone"` + Password string `json:"password"` + Code string `json:"code"` + Nickname string `json:"nickname"` + DeviceID string `json:"deviceId"` + Scene string `json:"scene"` +} + +func (a *App) sendSMS(w http.ResponseWriter, r *http.Request) { + var req authRequest + if err := decode(r, &req); err != nil || !validPhone(req.Phone) { + fail(w, http.StatusBadRequest, 20001, "请输入正确的手机号") + return + } + if !a.configBool(r.Context(), "sms.enabled", true) { + fail(w, http.StatusServiceUnavailable, 50002, "短信服务暂未开放") + return + } + if req.Scene == "" { + req.Scene = "login" + } + if req.Scene != "login" && req.Scene != "register" && req.Scene != "reset" && req.Scene != "change_phone" { + fail(w, http.StatusBadRequest, 20001, "验证码场景无效") + return + } + phone := strings.TrimSpace(req.Phone) + if !a.rateLimit(w, r, "sms_ip", clientIP(r), 20, time.Hour) || !a.rateLimit(w, r, "sms_phone", phone, 5, time.Hour) { + return + } + var recent int + _ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM sms_verification_codes WHERE phone_hash=? AND scene=? AND created_at>DATE_SUB(NOW(3),INTERVAL 60 SECOND)`, phoneHash(req.Phone), req.Scene).Scan(&recent) + if recent > 0 { + fail(w, http.StatusTooManyRequests, 20002, "请稍后再获取验证码") + return + } + provider := a.configPlain(r.Context(), "sms.provider", "debug") + if a.config.Environment == "production" && provider == "debug" { + fail(w, http.StatusServiceUnavailable, 50002, "生产环境禁止使用调试短信服务") + return + } + code := a.configPlain(r.Context(), "sms.debug_code", "123456") + if provider != "debug" { + var buffer [4]byte + _, _ = rand.Read(buffer[:]) + code = fmt.Sprintf("%06d", binary.BigEndian.Uint32(buffer[:])%1_000_000) + } + if err := a.dispatchSMS(r.Context(), phone, req.Scene, code); err != nil { + fail(w, http.StatusBadGateway, 50002, err.Error()) + return + } + expires, _ := strconv.Atoi(a.configPlain(r.Context(), "sms.expire_seconds", "300")) + if expires < 60 || expires > 1800 { + expires = 300 + } + codeHash := sha256.Sum256([]byte(code)) + _, err := a.db.ExecContext(r.Context(), `INSERT INTO sms_verification_codes(phone_hash,scene,code_hash,expires_at)VALUES(?,?,?,DATE_ADD(NOW(3),INTERVAL ? SECOND))`, phoneHash(req.Phone), req.Scene, codeHash[:], expires) + if err != nil { + fail(w, 500, 50001, "保存验证码失败") + return + } + data := map[string]any{"expiresIn": expires, "provider": provider} + if provider == "debug" { + data["debugCode"] = code + } + reply(w, data) +} + +func (a *App) register(w http.ResponseWriter, r *http.Request) { + var req authRequest + if err := decode(r, &req); err != nil { + fail(w, http.StatusBadRequest, 20001, err.Error()) + return + } + if !validPhone(req.Phone) || !validUserPassword(req.Password) || strings.TrimSpace(req.Nickname) == "" || len([]rune(strings.TrimSpace(req.Nickname))) > 50 { + fail(w, http.StatusBadRequest, 20001, "请填写有效的手机号、昵称和密码") + return + } + if !a.rateLimit(w, r, "register_ip", clientIP(r), 20, 10*time.Minute) || !a.rateLimit(w, r, "register_phone", strings.TrimSpace(req.Phone), 10, 10*time.Minute) { + return + } + if !a.consumeSMSCode(r, req.Phone, "register", req.Code) { + fail(w, http.StatusBadRequest, 20001, "验证码错误或已过期") + return + } + hash, err := hashPassword(req.Password) + if err != nil { + fail(w, 500, 50001, "创建账号失败") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "创建账号失败") + return + } + defer func() { _ = tx.Rollback() }() + publicID := fmt.Sprintf("XY%d%s", time.Now().UnixMilli(), randomToken()[:5]) + phoneCipher, encryptErr := a.encryptPhone(req.Phone) + if encryptErr != nil { + fail(w, 500, 50001, "加密账号信息失败") + return + } + result, err := tx.ExecContext(r.Context(), `INSERT INTO users (public_id,country_code,phone_hash,phone_cipher,password_hash) VALUES (?,'+86',?,?,?)`, publicID, phoneHash(req.Phone), phoneCipher, hash) + if err != nil { + fail(w, http.StatusConflict, 20001, "该手机号已注册") + return + } + userID, err := result.LastInsertId() + if err != nil { + fail(w, 500, 50001, "创建账号失败") + return + } + _, err = tx.ExecContext(r.Context(), `INSERT INTO user_profiles (user_id,nickname,bio,profile_score,last_active_at) VALUES (?,?, '遇见更好的陌生人',30,NOW(3))`, userID, req.Nickname) + if err != nil { + fail(w, 500, 50001, "创建资料失败") + return + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_privacy_settings (user_id) VALUES (?)`, userID); err != nil { + fail(w, 500, 50001, "创建账号失败") + return + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_notification_settings (user_id) VALUES (?)`, userID); err != nil { + fail(w, 500, 50001, "创建账号失败") + return + } + if err := tx.Commit(); err != nil { + fail(w, 500, 50001, "创建账号失败") + return + } + a.finishLogin(w, r, userID, req.Nickname, req.DeviceID) +} + +func (a *App) loginPassword(w http.ResponseWriter, r *http.Request) { + var req authRequest + if err := decode(r, &req); err != nil { + fail(w, 400, 20001, err.Error()) + return + } + if !validPhone(req.Phone) || req.Password == "" { + fail(w, http.StatusUnauthorized, 10001, "手机号或密码错误") + return + } + if !a.rateLimit(w, r, "login_ip", clientIP(r), 60, 10*time.Minute) || !a.rateLimit(w, r, "login_phone", strings.TrimSpace(req.Phone), 10, 10*time.Minute) { + return + } + var id int64 + var hash, nickname string + var status int + err := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.password_hash,u.status,p.nickname FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.phone_hash=? AND u.deleted_at IS NULL`, phoneHash(req.Phone)).Scan(&id, &hash, &status, &nickname) + if err != nil || !checkPassword(hash, req.Password) { + fail(w, http.StatusUnauthorized, 10001, "手机号或密码错误") + return + } + if status != 1 { + fail(w, http.StatusForbidden, 10006, "账号当前不可用") + return + } + a.finishLogin(w, r, id, nickname, req.DeviceID) +} + +func (a *App) loginSMS(w http.ResponseWriter, r *http.Request) { + var req authRequest + if err := decode(r, &req); err != nil || !validPhone(req.Phone) || len(req.Code) != 6 { + fail(w, 400, 20001, "验证码格式错误") + return + } + if !a.rateLimit(w, r, "sms_login_ip", clientIP(r), 30, 10*time.Minute) || !a.rateLimit(w, r, "sms_login_phone", strings.TrimSpace(req.Phone), 10, 10*time.Minute) { + return + } + if !a.consumeSMSCode(r, req.Phone, "login", req.Code) { + fail(w, 400, 20001, "验证码错误或已过期") + return + } + var id int64 + var nickname string + if err := a.db.QueryRowContext(r.Context(), `SELECT u.id,p.nickname FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.phone_hash=? AND u.status=1`, phoneHash(req.Phone)).Scan(&id, &nickname); err != nil { + fail(w, http.StatusUnauthorized, 10001, "账号不存在") + return + } + a.finishLogin(w, r, id, nickname, req.DeviceID) +} + +func (a *App) resetPassword(w http.ResponseWriter, r *http.Request) { + var req authRequest + if err := decode(r, &req); err != nil || !validPhone(req.Phone) || len(req.Code) != 6 || !validUserPassword(req.Password) { + fail(w, 400, 20001, "请填写有效的手机号、验证码和新密码") + return + } + if !a.rateLimit(w, r, "password_reset_ip", clientIP(r), 20, 10*time.Minute) || !a.rateLimit(w, r, "password_reset_phone", strings.TrimSpace(req.Phone), 10, 10*time.Minute) { + return + } + if !a.consumeSMSCode(r, req.Phone, "reset", req.Code) { + fail(w, 400, 20001, "验证码错误或已过期") + return + } + hash, err := hashPassword(req.Password) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "密码加密失败") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE users SET password_hash=? WHERE phone_hash=? AND deleted_at IS NULL`, hash, phoneHash(req.Phone)) + if err != nil { + fail(w, 500, 50001, "重置密码失败") + return + } + affected, _ := result.RowsAffected() + if affected == 0 { + fail(w, 404, 30001, "账号不存在") + return + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id IN (SELECT id FROM users WHERE phone_hash=?) AND revoked_at IS NULL`, phoneHash(req.Phone)) + _, _ = a.db.ExecContext(r.Context(), `INSERT INTO user_security_controls(user_id,token_version,force_logout_at,password_reset_at) SELECT id,1,NOW(3),NOW(3) FROM users WHERE phone_hash=? ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=VALUES(force_logout_at),password_reset_at=VALUES(password_reset_at)`, phoneHash(req.Phone)) + var resetUserID int64 + if a.db.QueryRowContext(r.Context(), `SELECT id FROM users WHERE phone_hash=?`, phoneHash(req.Phone)).Scan(&resetUserID) == nil { + a.hub.disconnect(resetUserID) + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) consumeSMSCode(r *http.Request, phone, scene, code string) bool { + if phone == "" || code == "" { + return false + } + var id int64 + var expected []byte + err := a.db.QueryRowContext(r.Context(), `SELECT id,code_hash FROM sms_verification_codes WHERE phone_hash=? AND scene=? AND used_at IS NULL AND expires_at>NOW(3) ORDER BY id DESC LIMIT 1`, phoneHash(phone), scene).Scan(&id, &expected) + if err != nil { + return false + } + actual := sha256.Sum256([]byte(code)) + if !bytesEqual(expected, actual[:]) { + return false + } + result, err := a.db.ExecContext(r.Context(), `UPDATE sms_verification_codes SET used_at=NOW(3) WHERE id=? AND used_at IS NULL`, id) + if err != nil { + return false + } + affected, _ := result.RowsAffected() + return affected == 1 +} + +func bytesEqual(left, right []byte) bool { + if len(left) != len(right) { + return false + } + var different byte + for index := range left { + different |= left[index] ^ right[index] + } + return different == 0 +} + +func (a *App) finishLogin(w http.ResponseWriter, r *http.Request, id int64, nickname, deviceID string) { + if deviceID == "" { + deviceID = "web-h5" + } + if len(deviceID) > 100 { + fail(w, http.StatusBadRequest, 20001, "设备标识过长") + return + } + accessToken, err := a.token(id, "user", nickname, 30*time.Minute) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建登录令牌失败") + return + } + refresh := randomToken() + refreshHash := sha256.Sum256([]byte(refresh)) + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建登录会话失败") + return + } + defer func() { _ = tx.Rollback() }() + if _, err = tx.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND device_id=? AND revoked_at IS NULL`, id, deviceID); err != nil { + fail(w, http.StatusInternalServerError, 50001, "更新设备会话失败") + return + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_sessions (user_id,device_id,refresh_token_hash,expires_at) VALUES (?,?,?,DATE_ADD(NOW(3), INTERVAL 60 DAY))`, id, deviceID, refreshHash[:]); err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建登录会话失败") + return + } + platform := strings.TrimSpace(r.Header.Get("X-Device-Platform")) + model := strings.TrimSpace(r.Header.Get("X-Device-Model")) + osVersion := strings.TrimSpace(r.Header.Get("X-OS-Version")) + if decoded, decodeErr := url.QueryUnescape(model); decodeErr == nil { + model = decoded + } + if decoded, decodeErr := url.QueryUnescape(osVersion); decodeErr == nil { + osVersion = decoded + } + appVersion := strings.TrimSpace(r.Header.Get("X-App-Version")) + if platform == "" { + platform = "unknown" + } + if len(platform) > 20 || len(model) > 100 || len(osVersion) > 50 || len(appVersion) > 30 { + fail(w, http.StatusBadRequest, 20001, "设备信息格式错误") + return + } + if _, err = tx.ExecContext(r.Context(), `INSERT INTO user_devices(user_id,device_id,platform,device_model,os_version,app_version,last_ip,last_active_at,status) + VALUES(?,?,?,?,?,?,?,NOW(3),1) + ON DUPLICATE KEY UPDATE platform=VALUES(platform),device_model=VALUES(device_model),os_version=VALUES(os_version),app_version=VALUES(app_version),last_ip=VALUES(last_ip),last_active_at=NOW(3),status=1`, id, deviceID, platform, model, osVersion, appVersion, clientIP(r)); err != nil { + fail(w, http.StatusInternalServerError, 50001, "保存设备信息失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET last_active_at=NOW(3) WHERE user_id=?`, id); err != nil || tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "保存登录状态失败") + return + } + reply(w, map[string]any{"accessToken": accessToken, "refreshToken": refresh, "expiresIn": 1800, "userId": id}) +} + +func (a *App) refreshToken(w http.ResponseWriter, r *http.Request) { + var req struct { + RefreshToken string `json:"refreshToken"` + } + if err := decode(r, &req); err != nil { + fail(w, 400, 20001, "refreshToken required") + return + } + oldHash := sha256.Sum256([]byte(req.RefreshToken)) + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "刷新登录状态失败") + return + } + defer func() { _ = tx.Rollback() }() + var sessionID, id int64 + var nickname, deviceID string + err = tx.QueryRowContext(r.Context(), `SELECT s.id,s.user_id,p.nickname,s.device_id FROM user_sessions s JOIN users u ON u.id=s.user_id JOIN user_profiles p ON p.user_id=s.user_id WHERE s.refresh_token_hash=? AND s.revoked_at IS NULL AND s.expires_at>NOW(3) AND u.status=1 AND u.deleted_at IS NULL FOR UPDATE`, oldHash[:]).Scan(&sessionID, &id, &nickname, &deviceID) + if err != nil { + fail(w, 401, 10001, "刷新令牌无效") + return + } + accessToken, err := a.token(id, "user", nickname, 30*time.Minute) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建登录令牌失败") + return + } + newRefresh := randomToken() + newHash := sha256.Sum256([]byte(newRefresh)) + result, err := tx.ExecContext(r.Context(), `UPDATE user_sessions SET refresh_token_hash=?,last_active_at=NOW(3) WHERE id=? AND refresh_token_hash=? AND revoked_at IS NULL`, newHash[:], sessionID, oldHash[:]) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "刷新登录状态失败") + return + } + affected, _ := result.RowsAffected() + if affected != 1 || tx.Commit() != nil { + fail(w, http.StatusUnauthorized, 10001, "刷新令牌已被使用") + return + } + _, _ = a.db.ExecContext(r.Context(), `UPDATE user_devices SET last_active_at=NOW(3),last_ip=? WHERE user_id=? AND device_id=?`, clientIP(r), id, deviceID) + reply(w, map[string]any{"accessToken": accessToken, "refreshToken": newRefresh, "expiresIn": 1800}) +} + +func (a *App) logout(w http.ResponseWriter, r *http.Request) { + who := current(r) + var req struct { + RefreshToken string `json:"refreshToken"` + } + if r.Body != nil && r.ContentLength != 0 { + _ = decode(r, &req) + } + if strings.TrimSpace(req.RefreshToken) != "" { + hash := sha256.Sum256([]byte(strings.TrimSpace(req.RefreshToken))) + _, _ = a.db.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND refresh_token_hash=? AND revoked_at IS NULL`, who.ID, hash[:]) + } else { + _, _ = a.db.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, who.ID) + } + _, _ = a.db.ExecContext(r.Context(), `INSERT INTO user_security_controls(user_id,token_version,force_logout_at) VALUES(?,1,NOW(3)) ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=VALUES(force_logout_at)`, who.ID) + a.hub.disconnect(who.ID) + reply(w, map[string]bool{"success": true}) +} + +func (a *App) me(w http.ResponseWriter, r *http.Request) { + profile, err := a.loadProfile(r, current(r).ID, current(r).ID) + if err != nil { + fail(w, 404, 30001, "用户不存在") + return + } + reply(w, profile) +} + +func (a *App) updateProfile(w http.ResponseWriter, r *http.Request) { + var req struct { + Nickname *string `json:"nickname"` + Avatar *string `json:"avatar"` + Cover *string `json:"cover"` + Bio *string `json:"bio"` + City *string `json:"city"` + Birthday *string `json:"birthday"` + Occupation *string `json:"occupation"` + Height *int `json:"height"` + Gender *int `json:"gender"` + Education *int `json:"education"` + RelationshipStatus *int `json:"relationshipStatus"` + TagIDs *[]int64 `json:"tagIds"` + } + if err := decode(r, &req); err != nil { + fail(w, 400, 20001, err.Error()) + return + } + who := current(r) + var nickname, avatar, cover, bio, city, occupation string + var birthday sql.NullString + var height sql.NullInt64 + var gender, education, relationship int + if err := a.db.QueryRowContext(r.Context(), `SELECT nickname,avatar_url,cover_url,bio,city_name,occupation,DATE_FORMAT(birthday,'%Y-%m-%d'),height_cm,gender,education,relationship_status FROM user_profiles WHERE user_id=?`, who.ID).Scan(&nickname, &avatar, &cover, &bio, &city, &occupation, &birthday, &height, &gender, &education, &relationship); err != nil { + fail(w, 500, 50001, "读取资料失败") + return + } + if req.Nickname != nil { + nickname = strings.TrimSpace(*req.Nickname) + } + if req.Avatar != nil { + avatar = strings.TrimSpace(*req.Avatar) + } + if req.Cover != nil { + cover = strings.TrimSpace(*req.Cover) + } + if req.Bio != nil { + bio = strings.TrimSpace(*req.Bio) + } + if req.City != nil { + city = strings.TrimSpace(*req.City) + } + if req.Occupation != nil { + occupation = strings.TrimSpace(*req.Occupation) + } + if req.Height != nil { + if *req.Height < 0 || *req.Height > 260 { + fail(w, 400, 20001, "身高范围无效") + return + } + height = sql.NullInt64{Int64: int64(*req.Height), Valid: *req.Height > 0} + } + if req.Gender != nil { + if *req.Gender < 0 || *req.Gender > 2 { + fail(w, 400, 20001, "性别选项无效") + return + } + gender = *req.Gender + } + if req.Education != nil { + if *req.Education < 0 || *req.Education > 10 { + fail(w, 400, 20001, "学历选项无效") + return + } + education = *req.Education + } + if req.RelationshipStatus != nil { + if *req.RelationshipStatus < 0 || *req.RelationshipStatus > 10 { + fail(w, 400, 20001, "情感状态无效") + return + } + relationship = *req.RelationshipStatus + } + if nickname == "" || len([]rune(nickname)) > 50 || len([]rune(bio)) > 500 || len([]rune(city)) > 50 || len([]rune(occupation)) > 100 { + fail(w, 400, 20001, "资料内容长度无效") + return + } + if req.Birthday != nil { + value := strings.TrimSpace(*req.Birthday) + if value == "" { + birthday = sql.NullString{} + } else { + parsed, parseErr := time.Parse("2006-01-02", value) + if parseErr != nil || parsed.After(time.Now().AddDate(-18, 0, 0)) || parsed.Before(time.Now().AddDate(-100, 0, 0)) { + fail(w, 400, 20001, "仅支持 18-100 周岁的生日日期") + return + } + birthday = sql.NullString{String: value, Valid: true} + } + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "保存失败") + return + } + defer func() { _ = tx.Rollback() }() + _, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET nickname=?,avatar_url=?,cover_url=?,bio=?,city_name=?,occupation=?,birthday=?,height_cm=?,gender=?,education=?,relationship_status=?,profile_score=GREATEST(profile_score,80) WHERE user_id=?`, nickname, avatar, cover, bio, city, occupation, birthday, height, gender, education, relationship, who.ID) + if err == nil && req.TagIDs != nil { + if len(*req.TagIDs) > 12 { + fail(w, 400, 20001, "最多选择 12 个标签") + return + } + _, err = tx.ExecContext(r.Context(), `DELETE FROM user_tags WHERE user_id=?`, who.ID) + for _, tagID := range *req.TagIDs { + if err != nil { + break + } + result, insertErr := tx.ExecContext(r.Context(), `INSERT INTO user_tags(user_id,tag_id) SELECT ?,id FROM tags WHERE id=? AND status=1`, who.ID, tagID) + err = insertErr + if err == nil { + affected, _ := result.RowsAffected() + if affected == 0 { + err = fmt.Errorf("标签不存在") + } + } + } + } + if err != nil || tx.Commit() != nil { + fail(w, 500, 50001, "保存失败") + return + } + a.me(w, r) +} + +func nullableString(v sql.NullString) string { + if v.Valid { + return v.String + } + return "" +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/rate_limit.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/rate_limit.go new file mode 100644 index 0000000..71e57a7 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/rate_limit.go @@ -0,0 +1,56 @@ +package app + +import ( + "context" + "crypto/sha256" + "fmt" + "net" + "net/http" + "strings" + "time" +) + +func (a *App) allowRequest(ctx context.Context, action, subject string, limit int, window time.Duration) bool { + if limit < 1 || window < time.Second { + return false + } + windowSeconds := int64(window / time.Second) + slot := time.Now().Unix() / windowSeconds + key := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d", action, subject, slot))) + expiresAt := time.Unix((slot+1)*windowSeconds, 0).Add(time.Minute) + _, err := a.db.ExecContext(ctx, `INSERT INTO api_rate_limits(bucket_key,action_name,hits,expires_at) VALUES(?,?,1,?) ON DUPLICATE KEY UPDATE hits=hits+1,expires_at=VALUES(expires_at)`, key[:], action, expiresAt) + if err != nil { + return false + } + var hits int + if err = a.db.QueryRowContext(ctx, `SELECT hits FROM api_rate_limits WHERE bucket_key=?`, key[:]).Scan(&hits); err != nil { + return false + } + if key[0] == 0 { + _, _ = a.db.ExecContext(ctx, `DELETE FROM api_rate_limits WHERE expires_at 255 { + t.Fatal("long passwords require unique salts and a hash that fits existing storage") + } + if !checkPassword(first, password) || checkPassword(first, legacyPassword+"b") || checkPassword(first, legacyPassword) { + t.Fatal("the entire long password must participate in verification") + } + for _, invalid := range []string{"", "plaintext", "$argon2id$", strings.Replace(first, "m=19456", "m=999999999", 1), first[:len(first)-1], longPasswordHashPrefix + strings.Repeat("!", 66)} { + if checkPassword(invalid, password) { + t.Fatal("malformed hash was accepted") + } + } +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/seed.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/seed.go new file mode 100644 index 0000000..22b8771 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/seed.go @@ -0,0 +1,191 @@ +package app + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/json" + "fmt" + "time" +) + +type demoUser struct { + Phone, Nickname, Avatar, Cover, City, Bio string + Gender, Age, VIP int + Lat, Lng float64 +} + +var demoUsers = []demoUser{ + {"13800138000", "小甜心", "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=600&auto=format&fit=crop", "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200&auto=format&fit=crop", "上海", "热爱生活,喜欢记录美好瞬间", 2, 23, 2, 31.2304, 121.4737}, + {"13800138001", "小鹿心", "https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=600&auto=format&fit=crop", "https://images.unsplash.com/photo-1519608487953-e999c86e7455?w=1200&auto=format&fit=crop", "上海", "摄影、旅行和一切浪漫的事", 2, 23, 1, 31.2310, 121.4750}, + {"13800138002", "爱笑的眼睛", "https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=600&auto=format&fit=crop", "https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=1200&auto=format&fit=crop", "上海", "愿每一天都有新的故事", 2, 24, 1, 31.2289, 121.4701}, + {"13800138003", "一只可爱喵", "https://images.unsplash.com/photo-1517841905240-472988babdf9?w=600&auto=format&fit=crop", "https://images.unsplash.com/photo-1497436072909-f5e4be1713c0?w=1200&auto=format&fit=crop", "上海", "咖啡重度爱好者", 2, 23, 0, 31.2260, 121.4690}, + {"13800138004", "星辰大海", "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=600&auto=format&fit=crop", "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?w=1200&auto=format&fit=crop", "上海", "周末去爬山吧", 1, 25, 0, 31.2248, 121.4810}, + {"13800138005", "南音不渝", "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=600&auto=format&fit=crop", "https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?w=1200&auto=format&fit=crop", "上海", "听歌、跑步、看展", 1, 24, 0, 31.2204, 121.4760}, + {"13800138006", "温柔的风", "https://images.unsplash.com/photo-1531123897727-8f129e1688ce?w=600&auto=format&fit=crop", "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=1200&auto=format&fit=crop", "上海", "想遇见同频的人", 2, 24, 1, 31.2184, 121.4860}, + {"13800138007", "月亮邮递员", "https://images.unsplash.com/photo-1531746020798-e6953c6e8e04?w=600&auto=format&fit=crop", "https://images.unsplash.com/photo-1470252649378-9c29740c9fa8?w=1200&auto=format&fit=crop", "上海", "收集晚霞和好心情", 2, 24, 1, 31.2154, 121.4710}, +} + +func (a *App) Seed() error { + var tableCount int + if err := a.db.QueryRow(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'users'`).Scan(&tableCount); err != nil { + return err + } + if tableCount == 0 { + return fmt.Errorf("数据库尚未初始化,请先运行 scripts/migrate.ps1") + } + if err := a.encryptLegacyPhones(context.Background()); err != nil { + return fmt.Errorf("encrypt legacy phone data: %w", err) + } + + adminUsername := a.config.BootstrapAdminUsername + adminPassword := a.config.BootstrapAdminPassword + adminRealName := a.config.BootstrapAdminRealName + if a.config.SeedDemo && adminPassword == "" { + adminUsername = "admin" + adminPassword = "Admin@123" + } + if adminPassword != "" { + if adminUsername == "" { + return fmt.Errorf("已配置管理员密码,但 IM_BOOTSTRAP_ADMIN_USERNAME 为空") + } + adminHash, hashErr := hashPassword(adminPassword) + if hashErr != nil { + return hashErr + } + _, err := a.db.Exec(`INSERT INTO admin_users (username,password_hash,real_name,avatar_url,status) + VALUES (?,?,?,?,1) ON DUPLICATE KEY UPDATE real_name=VALUES(real_name)`, adminUsername, adminHash, adminRealName, demoUsers[0].Avatar) + if err != nil { + return err + } + _, err = a.db.Exec(`INSERT IGNORE INTO admin_user_roles(admin_user_id,role_id) + SELECT a.id,r.id FROM admin_users a JOIN admin_roles r ON r.role_code='super_admin' WHERE a.username=?`, adminUsername) + if err != nil { + return fmt.Errorf("assign bootstrap administrator role: %w", err) + } + } + var adminCount int + if err := a.db.QueryRow(`SELECT COUNT(*) FROM admin_users WHERE status=1`).Scan(&adminCount); err != nil { + return err + } + if adminCount == 0 { + return fmt.Errorf("没有可用管理员,请配置 IM_BOOTSTRAP_ADMIN_USERNAME 和 IM_BOOTSTRAP_ADMIN_PASSWORD") + } + if !a.config.SeedDemo { + return nil + } + + var users int + if err := a.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&users); err != nil { + return err + } + if users > 0 { + return nil + } + + passwordHash, err := hashPassword("123456") + if err != nil { + return err + } + tx, err := a.db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + ids := make([]int64, 0, len(demoUsers)) + for index, item := range demoUsers { + birthday := time.Now().AddDate(-item.Age, 0, 0).Format("2006-01-02") + phoneCipher, encryptErr := a.encryptPhone(item.Phone) + if encryptErr != nil { + return encryptErr + } + result, execErr := tx.Exec(`INSERT INTO users (public_id,country_code,phone_hash,phone_cipher,password_hash,status,risk_level) + VALUES (?,?,?,?,?,1,0)`, fmt.Sprintf("XY%08d", index+10001), "+86", phoneHash(item.Phone), phoneCipher, passwordHash) + if execErr != nil { + return execErr + } + id, _ := result.LastInsertId() + ids = append(ids, id) + _, execErr = tx.Exec(`INSERT INTO user_profiles + (user_id,nickname,avatar_url,cover_url,gender,birthday,height_cm,city_code,city_name,occupation,bio,profile_score,is_vip,vip_level,last_active_at) + VALUES (?,?,?,?,?,?,?,?,'上海',?,?,95,?,?,?)`, id, item.Nickname, item.Avatar, item.Cover, item.Gender, birthday, 163+index%12, "310100", "创意行业", item.Bio, btoi(item.VIP > 0), item.VIP, time.Now().Add(-time.Duration(index*4)*time.Minute)) + if execErr != nil { + return execErr + } + _, _ = tx.Exec(`INSERT INTO user_privacy_settings (user_id) VALUES (?)`, id) + _, _ = tx.Exec(`INSERT INTO user_location_states (user_id,city_code,location_cell,latitude,longitude,source) VALUES (?,'310100','wx4g',?,?,'seed')`, id, item.Lat, item.Lng) + _, _ = tx.Exec(`INSERT INTO user_risk_profiles (user_id,risk_score,risk_level) VALUES (?, ?, ?)`, id, index*3, btoi(index == 7)) + } + + mediaSets := [][]string{ + {"https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=900&auto=format&fit=crop", "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=900&auto=format&fit=crop", "https://images.unsplash.com/photo-1470770841072-f978cf4d019e?w=900&auto=format&fit=crop"}, + {"https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?w=900&auto=format&fit=crop", "https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?w=900&auto=format&fit=crop", "https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=900&auto=format&fit=crop"}, + {"https://images.unsplash.com/photo-1470252649378-9c29740c9fa8?w=900&auto=format&fit=crop"}, + } + contents := []string{"今天的天空很美,心情也很好~", "周末去爬山啦", "晚霞也太治愈了吧"} + for index, content := range contents { + result, execErr := tx.Exec(`INSERT INTO posts (user_id,content,city_code,location_text,like_count,comment_count) VALUES (?,?, '310100','上海',?,?)`, ids[index+1], content, 23+index*13, 8+index*2) + if execErr != nil { + return execErr + } + postID, _ := result.LastInsertId() + for order, url := range mediaSets[index] { + _, _ = tx.Exec(`INSERT INTO post_media (post_id,media_url,media_type,sort_order) VALUES (?,?,'image',?)`, postID, url, order) + } + } + + for i := 1; i < len(ids); i++ { + _, _ = tx.Exec(`INSERT INTO user_follows (user_id,target_user_id) VALUES (?,?)`, ids[0], ids[i]) + if i < 5 { + _, _ = tx.Exec(`INSERT INTO user_likes (user_id,target_user_id,source) VALUES (?,?, 'seed')`, ids[0], ids[i]) + } + } + + conversationResult, err := tx.Exec(`INSERT INTO im_conversations (conversation_type,last_seq,last_message_at) VALUES (1,3,NOW(3))`) + if err != nil { + return err + } + conversationID, _ := conversationResult.LastInsertId() + _, _ = tx.Exec(`INSERT INTO im_direct_conversations (conversation_id,user1_id,user2_id) VALUES (?,?,?)`, conversationID, ids[0], ids[1]) + _, _ = tx.Exec(`INSERT INTO im_conversation_members (conversation_id,user_id,read_seq,delivered_seq) VALUES (?,?,3,3),(?,?,1,3)`, conversationID, ids[0], conversationID, ids[1]) + messages := []struct { + sender int64 + text string + }{{ids[1], "今天的晚霞好美呀~"}, {ids[0], "阳光正好,想和你去看一次日落"}, {ids[1], "好呀好呀,我也正想去看呢!"}} + for index, message := range messages { + body, _ := json.Marshal(map[string]string{"text": message.text}) + result, execErr := tx.Exec(`INSERT INTO im_messages (conversation_id,seq,sender_id,client_msg_id,message_type,body) VALUES (?,?,?,?,1,?)`, conversationID, index+1, message.sender, fmt.Sprintf("01JDEMO%019d", index+1), body) + if execErr != nil { + return execErr + } + if index == len(messages)-1 { + messageID, _ := result.LastInsertId() + _, _ = tx.Exec(`UPDATE im_conversations SET last_message_id=? WHERE id=?`, messageID, conversationID) + } + } + + _, _ = tx.Exec(`INSERT INTO notifications (user_id,type,title,content,biz_type,biz_id) VALUES + (?,'follow','新的关注','爱笑的眼睛关注了你','user',?), + (?,'like','新的喜欢','小鹿心喜欢了你','user',?), + (?,'system','欢迎来到星遇','完善资料可以获得更多推荐','',NULL)`, ids[0], ids[2], ids[0], ids[1], ids[0]) + _, _ = tx.Exec(`INSERT INTO reports (reporter_user_id,target_type,target_id,reason_code,description,status) VALUES (?, 'user', ?, 'advertising', '频繁发送广告链接', 'PENDING')`, ids[2], ids[7]) + _, _ = tx.Exec(`INSERT INTO risk_events (user_id,event_type,score_delta,device_id,ip,metadata) VALUES (?, 'rapid_messages', 12, 'demo-device', '127.0.0.1', JSON_OBJECT('count', 32))`, ids[7]) + _, _ = tx.Exec(`INSERT INTO orders (order_no,user_id,product_type,product_id,amount_cent,status,channel,paid_at) VALUES ('XYDEMO202608240001', ?, 'membership', 2, 6800, 'PAID', 'alipay', NOW(3))`, ids[0]) + return tx.Commit() +} + +func btoi(value bool) int { + if value { + return 1 + } + return 0 +} + +func scanNullableString(value sql.NullString) string { + if value.Valid { + return value.String + } + return "" +} + +func sha(value string) []byte { sum := sha256.Sum256([]byte(value)); return sum[:] } diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/sms_providers.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/sms_providers.go new file mode 100644 index 0000000..be4f49a --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/sms_providers.go @@ -0,0 +1,416 @@ +package app + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const smsResponseLimit = 256 << 10 + +type smsDeliveryResult struct { + MessageID string +} + +func parseSMSProviderEndpoint(provider, raw string) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf("%s API 地址必须是无账号、查询参数和片段的有效 HTTPS 地址", cloudSMSProviderName(provider)) + } + path := strings.TrimRight(parsed.EscapedPath(), "/") + switch provider { + case "aliyun", "tencent": + if path != "" { + return nil, fmt.Errorf("%s API 地址不能包含路径", cloudSMSProviderName(provider)) + } + case "huawei": + if !strings.HasSuffix(path, "/sms/batchSendSms/v1") { + return nil, fmt.Errorf("华为云 APP 接入地址必须包含 /sms/batchSendSms/v1") + } + } + return parsed, nil +} + +func (a *App) validateSMSProviderConfig(ctx context.Context) error { + provider := a.configPlain(ctx, "sms.provider", "debug") + if !containsString([]string{"aliyun", "tencent", "huawei", "webhook", "debug"}, provider) { + return fmt.Errorf("不支持的短信厂商 %q", provider) + } + if a.config.Environment == "production" && provider == "debug" { + return fmt.Errorf("生产环境禁止使用 debug 短信提供商") + } + for _, spec := range integrationSpecs["sms"] { + if !spec.Required || (len(spec.Providers) > 0 && !containsString(spec.Providers, provider)) { + continue + } + if strings.TrimSpace(a.configPlain(ctx, spec.Key, "")) == "" { + return fmt.Errorf("请填写%s", spec.Label) + } + } + + switch provider { + case "aliyun", "tencent", "huawei": + endpoint := a.configPlain(ctx, "sms."+provider+".endpoint", "") + if _, err := parseSMSProviderEndpoint(provider, endpoint); err != nil { + return err + } + if _, err := a.smsTemplateParams(ctx, provider, "000000"); err != nil { + return err + } + case "webhook": + endpoint := a.configPlain(ctx, "sms.webhook_url", "") + if a.config.Environment == "production" && !validHTTPSURL(endpoint) { + return fmt.Errorf("生产环境 Webhook 地址必须使用 HTTPS") + } + } + return nil +} + +func cloudSMSProviderName(provider string) string { + switch provider { + case "aliyun": + return "阿里云" + case "tencent": + return "腾讯云" + case "huawei": + return "华为云" + case "webhook": + return "Webhook" + case "debug": + return "本地调试" + default: + return provider + } +} + +func (a *App) smsTemplateID(ctx context.Context, provider, scene string) (string, error) { + if !containsString([]string{"register", "login", "reset"}, scene) { + return "", fmt.Errorf("短信验证码场景无效") + } + key := "sms." + provider + ".template_" + scene + if provider == "webhook" { + key = "sms.template_" + scene + } + value := strings.TrimSpace(a.configPlain(ctx, key, "")) + if value == "" { + return "", fmt.Errorf("%s%s模板未配置", cloudSMSProviderName(provider), scene) + } + return value, nil +} + +func (a *App) smsTemplateParams(ctx context.Context, provider, code string) (string, error) { + raw := a.configPlain(ctx, "sms."+provider+".template_params", "") + expires, _ := strconv.Atoi(a.configPlain(ctx, "sms.expire_seconds", "300")) + return renderSMSTemplateParams(provider, raw, code, expires) +} + +func renderSMSTemplateParams(provider, raw, code string, expires int) (string, error) { + if expires < 60 || expires > 1800 { + expires = 300 + } + minutes := (expires + 59) / 60 + rendered := strings.ReplaceAll(raw, "{{code}}", code) + rendered = strings.ReplaceAll(rendered, "{{minutes}}", strconv.Itoa(minutes)) + if provider == "aliyun" { + var object map[string]any + if json.Unmarshal([]byte(rendered), &object) != nil || len(object) == 0 { + return "", fmt.Errorf("阿里云模板变量必须是有效的非空 JSON 对象") + } + payload, _ := json.Marshal(object) + return string(payload), nil + } + var values []string + if json.Unmarshal([]byte(rendered), &values) != nil || len(values) == 0 { + return "", fmt.Errorf("%s模板参数必须是有效的非空 JSON 字符串数组", cloudSMSProviderName(provider)) + } + payload, _ := json.Marshal(values) + return string(payload), nil +} + +func huaweiWSSE(appKey, appSecret, nonce, created string) (string, string) { + digestHash := sha256.Sum256([]byte(nonce + created + appSecret)) + passwordDigest := base64.StdEncoding.EncodeToString(digestHash[:]) + authorization := `WSSE realm="SDP",profile="UsernameToken",type="Appkey"` + wsse := `UsernameToken Username="` + appKey + `",PasswordDigest="` + passwordDigest + `",Nonce="` + nonce + `",Created="` + created + `"` + return authorization, wsse +} + +func smsHTTPClient() *http.Client { + return &http.Client{Timeout: 8 * time.Second} +} + +func readSMSResponse(response *http.Response) ([]byte, error) { + body, err := io.ReadAll(io.LimitReader(response.Body, smsResponseLimit)) + if err != nil { + return nil, err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("短信厂商返回 HTTP %d", response.StatusCode) + } + return body, nil +} + +func providerError(provider, code, message string) error { + message = strings.TrimSpace(message) + if len(message) > 300 { + message = message[:300] + } + if message == "" { + message = "请求失败" + } + return fmt.Errorf("%s短信发送失败 [%s]: %s", cloudSMSProviderName(provider), code, message) +} + +func (a *App) sendAliyunSMS(ctx context.Context, phone, scene, code string) (smsDeliveryResult, error) { + endpoint := a.configPlain(ctx, "sms.aliyun.endpoint", "https://dysmsapi.aliyuncs.com") + parsed, err := parseSMSProviderEndpoint("aliyun", endpoint) + if err != nil { + return smsDeliveryResult{}, err + } + templateID, err := a.smsTemplateID(ctx, "aliyun", scene) + if err != nil { + return smsDeliveryResult{}, err + } + templateParams, err := a.smsTemplateParams(ctx, "aliyun", code) + if err != nil { + return smsDeliveryResult{}, err + } + query := url.Values{ + "PhoneNumbers": {phone}, + "SignName": {a.configPlain(ctx, "sms.aliyun.sign_name", "")}, + "TemplateCode": {templateID}, + "TemplateParam": {templateParams}, + } + canonicalQuery := strings.ReplaceAll(query.Encode(), "+", "%20") + parsed.RawQuery = canonicalQuery + canonicalURI := "/" + now := time.Now().UTC().Format("2006-01-02T15:04:05Z") + nonce := randomToken()[:32] + emptyHash := sha256.Sum256(nil) + payloadHash := hex.EncodeToString(emptyHash[:]) + canonicalHeaders := "host:" + parsed.Host + "\n" + + "x-acs-action:SendSms\n" + + "x-acs-content-sha256:" + payloadHash + "\n" + + "x-acs-date:" + now + "\n" + + "x-acs-signature-nonce:" + nonce + "\n" + + "x-acs-version:2017-05-25\n" + signedHeaders := "host;x-acs-action;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version" + canonicalRequest := "POST\n" + canonicalURI + "\n" + canonicalQuery + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + payloadHash + requestHash := sha256.Sum256([]byte(canonicalRequest)) + stringToSign := "ACS3-HMAC-SHA256\n" + hex.EncodeToString(requestHash[:]) + accessKeyID := a.configPlain(ctx, "sms.aliyun.access_key_id", "") + mac := hmac.New(sha256.New, []byte(a.configPlain(ctx, "sms.aliyun.access_key_secret", ""))) + _, _ = mac.Write([]byte(stringToSign)) + authorization := "ACS3-HMAC-SHA256 Credential=" + accessKeyID + ",SignedHeaders=" + signedHeaders + ",Signature=" + hex.EncodeToString(mac.Sum(nil)) + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), nil) + if err != nil { + return smsDeliveryResult{}, err + } + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", authorization) + request.Header.Set("x-acs-action", "SendSms") + request.Header.Set("x-acs-content-sha256", payloadHash) + request.Header.Set("x-acs-date", now) + request.Header.Set("x-acs-signature-nonce", nonce) + request.Header.Set("x-acs-version", "2017-05-25") + response, err := smsHTTPClient().Do(request) + if err != nil { + return smsDeliveryResult{}, fmt.Errorf("阿里云短信连接失败: %w", err) + } + defer response.Body.Close() + body, err := readSMSResponse(response) + if err != nil { + return smsDeliveryResult{}, err + } + var result struct { + BizID string `json:"BizId"` + Code string `json:"Code"` + Message string `json:"Message"` + RequestID string `json:"RequestId"` + } + if json.Unmarshal(body, &result) != nil { + return smsDeliveryResult{}, fmt.Errorf("阿里云短信返回无效 JSON") + } + if result.Code != "OK" { + return smsDeliveryResult{}, providerError("aliyun", result.Code, result.Message) + } + return smsDeliveryResult{MessageID: result.BizID}, nil +} + +func hmacSHA256(key, value []byte) []byte { + mac := hmac.New(sha256.New, key) + _, _ = mac.Write(value) + return mac.Sum(nil) +} + +func (a *App) sendTencentSMS(ctx context.Context, phone, scene, code string) (smsDeliveryResult, error) { + endpoint := a.configPlain(ctx, "sms.tencent.endpoint", "https://sms.tencentcloudapi.com") + parsed, err := parseSMSProviderEndpoint("tencent", endpoint) + if err != nil { + return smsDeliveryResult{}, err + } + templateID, err := a.smsTemplateID(ctx, "tencent", scene) + if err != nil { + return smsDeliveryResult{}, err + } + paramsJSON, err := a.smsTemplateParams(ctx, "tencent", code) + if err != nil { + return smsDeliveryResult{}, err + } + var params []string + _ = json.Unmarshal([]byte(paramsJSON), ¶ms) + payload, _ := json.Marshal(map[string]any{ + "PhoneNumberSet": []string{"+86" + phone}, + "SignName": a.configPlain(ctx, "sms.tencent.sign_name", ""), + "SmsSdkAppId": a.configPlain(ctx, "sms.tencent.sdk_app_id", ""), + "TemplateId": templateID, + "TemplateParamSet": params, + }) + timestamp := time.Now().Unix() + date := time.Unix(timestamp, 0).UTC().Format("2006-01-02") + contentType := "application/json; charset=utf-8" + canonicalHeaders := "content-type:" + contentType + "\nhost:" + parsed.Host + "\n" + signedHeaders := "content-type;host" + payloadHash := sha256.Sum256(payload) + canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hex.EncodeToString(payloadHash[:]) + canonicalHash := sha256.Sum256([]byte(canonicalRequest)) + credentialScope := date + "/sms/tc3_request" + stringToSign := "TC3-HMAC-SHA256\n" + strconv.FormatInt(timestamp, 10) + "\n" + credentialScope + "\n" + hex.EncodeToString(canonicalHash[:]) + secretKey := a.configPlain(ctx, "sms.tencent.secret_key", "") + secretDate := hmacSHA256([]byte("TC3"+secretKey), []byte(date)) + secretService := hmacSHA256(secretDate, []byte("sms")) + secretSigning := hmacSHA256(secretService, []byte("tc3_request")) + signature := hex.EncodeToString(hmacSHA256(secretSigning, []byte(stringToSign))) + authorization := "TC3-HMAC-SHA256 Credential=" + a.configPlain(ctx, "sms.tencent.secret_id", "") + "/" + credentialScope + ", SignedHeaders=" + signedHeaders + ", Signature=" + signature + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload)) + if err != nil { + return smsDeliveryResult{}, err + } + request.Header.Set("Authorization", authorization) + request.Header.Set("Content-Type", contentType) + request.Header.Set("X-TC-Action", "SendSms") + request.Header.Set("X-TC-Version", "2021-01-11") + request.Header.Set("X-TC-Timestamp", strconv.FormatInt(timestamp, 10)) + request.Header.Set("X-TC-Region", a.configPlain(ctx, "sms.tencent.region", "ap-guangzhou")) + response, err := smsHTTPClient().Do(request) + if err != nil { + return smsDeliveryResult{}, fmt.Errorf("腾讯云短信连接失败: %w", err) + } + defer response.Body.Close() + body, err := readSMSResponse(response) + if err != nil { + return smsDeliveryResult{}, err + } + var result struct { + Response struct { + Error *struct { + Code string `json:"Code"` + Message string `json:"Message"` + } `json:"Error"` + RequestID string `json:"RequestId"` + SendStatusSet []struct { + Code string `json:"Code"` + Message string `json:"Message"` + SerialNo string `json:"SerialNo"` + } `json:"SendStatusSet"` + } `json:"Response"` + } + if json.Unmarshal(body, &result) != nil { + return smsDeliveryResult{}, fmt.Errorf("腾讯云短信返回无效 JSON") + } + if result.Response.Error != nil { + return smsDeliveryResult{}, providerError("tencent", result.Response.Error.Code, result.Response.Error.Message) + } + if len(result.Response.SendStatusSet) == 0 { + return smsDeliveryResult{}, fmt.Errorf("腾讯云短信未返回发送状态") + } + status := result.Response.SendStatusSet[0] + if !strings.EqualFold(status.Code, "Ok") { + return smsDeliveryResult{}, providerError("tencent", status.Code, status.Message) + } + return smsDeliveryResult{MessageID: status.SerialNo}, nil +} + +func (a *App) sendHuaweiSMS(ctx context.Context, phone, scene, code string) (smsDeliveryResult, error) { + endpoint := a.configPlain(ctx, "sms.huawei.endpoint", "") + parsed, err := parseSMSProviderEndpoint("huawei", endpoint) + if err != nil { + return smsDeliveryResult{}, err + } + templateID, err := a.smsTemplateID(ctx, "huawei", scene) + if err != nil { + return smsDeliveryResult{}, err + } + paramsJSON, err := a.smsTemplateParams(ctx, "huawei", code) + if err != nil { + return smsDeliveryResult{}, err + } + appKey := a.configPlain(ctx, "sms.huawei.app_key", "") + nonce := randomToken()[:32] + created := time.Now().UTC().Format("2006-01-02T15:04:05Z") + authorization, wsse := huaweiWSSE(appKey, a.configPlain(ctx, "sms.huawei.app_secret", ""), nonce, created) + form := url.Values{ + "from": {a.configPlain(ctx, "sms.huawei.sender", "")}, + "to": {"+86" + phone}, + "templateId": {templateID}, + "templateParas": {paramsJSON}, + } + if signature := strings.TrimSpace(a.configPlain(ctx, "sms.huawei.signature", "")); signature != "" { + form.Set("signature", signature) + } + if callback := strings.TrimSpace(a.configPlain(ctx, "sms.huawei.status_callback", "")); callback != "" { + form.Set("statusCallback", callback) + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), strings.NewReader(form.Encode())) + if err != nil { + return smsDeliveryResult{}, err + } + request.Header.Set("Accept", "application/json") + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Authorization", authorization) + request.Header.Set("X-WSSE", wsse) + response, err := smsHTTPClient().Do(request) + if err != nil { + return smsDeliveryResult{}, fmt.Errorf("华为云短信连接失败: %w", err) + } + defer response.Body.Close() + body, err := readSMSResponse(response) + if err != nil { + return smsDeliveryResult{}, err + } + var result struct { + Code string `json:"code"` + Description string `json:"description"` + Result []struct { + MessageID string `json:"smsMsgId"` + Status string `json:"status"` + } `json:"result"` + } + if json.Unmarshal(body, &result) != nil { + return smsDeliveryResult{}, fmt.Errorf("华为云短信返回无效 JSON") + } + if result.Code != "000000" { + return smsDeliveryResult{}, providerError("huawei", result.Code, result.Description) + } + if len(result.Result) == 0 || result.Result[0].Status != "000000" { + status := "EMPTY_RESULT" + if len(result.Result) > 0 { + status = result.Result[0].Status + } + return smsDeliveryResult{}, providerError("huawei", status, "短信未被平台接受") + } + return smsDeliveryResult{MessageID: result.Result[0].MessageID}, nil +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/sms_providers_test.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/sms_providers_test.go new file mode 100644 index 0000000..9971b59 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/sms_providers_test.go @@ -0,0 +1,52 @@ +package app + +import ( + "strings" + "testing" +) + +func TestRenderSMSTemplateParams(t *testing.T) { + aliyun, err := renderSMSTemplateParams("aliyun", `{"code":"{{code}}","minutes":"{{minutes}}"}`, "086421", 301) + if err != nil || aliyun != `{"code":"086421","minutes":"6"}` { + t.Fatalf("unexpected aliyun template parameters: %q, %v", aliyun, err) + } + tencent, err := renderSMSTemplateParams("tencent", `["{{code}}","{{minutes}}"]`, "086421", 300) + if err != nil || tencent != `["086421","5"]` { + t.Fatalf("unexpected tencent template parameters: %q, %v", tencent, err) + } + if _, err = renderSMSTemplateParams("huawei", `{"code":"{{code}}"}`, "086421", 300); err == nil { + t.Fatal("huawei parameters must reject a non-array JSON value") + } +} + +func TestHuaweiWSSE(t *testing.T) { + authorization, wsse := huaweiWSSE("app-key", "secret", "abc", "2026-08-25T10:00:00Z") + if authorization != `WSSE realm="SDP",profile="UsernameToken",type="Appkey"` { + t.Fatalf("unexpected authorization header: %s", authorization) + } + if !strings.Contains(wsse, `Username="app-key"`) || !strings.Contains(wsse, `PasswordDigest="imMh4+lxH6z6wYNFDE3+ycvpRGbC7S/R2Q7ehhnXEqU="`) { + t.Fatalf("unexpected X-WSSE header: %s", wsse) + } +} + +func TestParseSMSProviderEndpoint(t *testing.T) { + tests := []struct { + provider string + endpoint string + valid bool + }{ + {provider: "aliyun", endpoint: "https://dysmsapi.aliyuncs.com", valid: true}, + {provider: "tencent", endpoint: "https://sms.tencentcloudapi.com/", valid: true}, + {provider: "huawei", endpoint: "https://smsapi.cn-north-4.myhuaweicloud.com:443/sms/batchSendSms/v1", valid: true}, + {provider: "aliyun", endpoint: "http://dysmsapi.aliyuncs.com", valid: false}, + {provider: "tencent", endpoint: "https://sms.tencentcloudapi.com/custom", valid: false}, + {provider: "huawei", endpoint: "https://user:pass@example.com/sms/batchSendSms/v1", valid: false}, + {provider: "huawei", endpoint: "https://example.com/sms/batchSendSms/v1?token=secret", valid: false}, + } + for _, test := range tests { + _, err := parseSMSProviderEndpoint(test.provider, test.endpoint) + if (err == nil) != test.valid { + t.Errorf("parseSMSProviderEndpoint(%q, %q) error = %v, valid = %v", test.provider, test.endpoint, err, test.valid) + } + } +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/social.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/social.go new file mode 100644 index 0000000..7e4761f --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/social.go @@ -0,0 +1,658 @@ +package app + +import ( + "database/sql" + "encoding/json" + "fmt" + "math" + "net/http" + "strconv" + "strings" + "time" +) + +type profileView struct { + ID int64 `json:"id"` + PublicID string `json:"publicId"` + IsTest bool `json:"isTest"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Cover string `json:"cover"` + Gender int `json:"gender"` + Age int `json:"age"` + Height int `json:"height"` + City string `json:"city"` + Birthday string `json:"birthday"` + Occupation string `json:"occupation"` + Education int `json:"education"` + RelationshipStatus int `json:"relationshipStatus"` + Bio string `json:"bio"` + VIP bool `json:"vip"` + VIPLevel int `json:"vipLevel"` + Online bool `json:"online"` + LastActiveAt *time.Time `json:"lastActiveAt,omitempty"` + Distance float64 `json:"distance"` + DistanceText string `json:"distanceText"` + FollowingCount int `json:"followingCount"` + FollowerCount int `json:"followerCount"` + PostCount int `json:"postCount"` + LikeCount int `json:"likeCount"` + Following bool `json:"following"` + Liked bool `json:"liked"` + Tags []string `json:"tags"` +} + +func (a *App) loadProfile(r *http.Request, id, viewerID int64) (profileView, error) { + var item profileView + var birthday sql.NullTime + var active sql.NullTime + var vip int + var onlineVisible, lastActiveVisible int + err := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.public_id,u.is_test,p.nickname,p.avatar_url,p.cover_url,p.gender,p.birthday,COALESCE(p.height_cm,0),p.city_name,p.occupation,p.education,p.relationship_status,p.bio,p.is_vip,p.vip_level,p.last_active_at,privacy.online_visible,privacy.last_active_visible, + (SELECT COUNT(*) FROM user_follows WHERE user_id=u.id),(SELECT COUNT(*) FROM user_follows WHERE target_user_id=u.id),(SELECT COUNT(*) FROM posts WHERE user_id=u.id AND status=1),(SELECT COUNT(*) FROM post_likes pl JOIN posts po ON po.id=pl.post_id WHERE po.user_id=u.id), + EXISTS(SELECT 1 FROM user_follows WHERE user_id=? AND target_user_id=u.id),EXISTS(SELECT 1 FROM user_likes WHERE user_id=? AND target_user_id=u.id) + FROM users u JOIN user_profiles p ON p.user_id=u.id JOIN user_privacy_settings privacy ON privacy.user_id=u.id + WHERE u.id=? AND u.status=1 AND (u.id=? OR NOT EXISTS(SELECT 1 FROM user_blocks blocked WHERE (blocked.user_id=? AND blocked.blocked_user_id=u.id) OR (blocked.user_id=u.id AND blocked.blocked_user_id=?)))`, viewerID, viewerID, id, viewerID, viewerID, viewerID).Scan( + &item.ID, &item.PublicID, &item.IsTest, &item.Nickname, &item.Avatar, &item.Cover, &item.Gender, &birthday, &item.Height, &item.City, &item.Occupation, &item.Education, &item.RelationshipStatus, &item.Bio, &vip, &item.VIPLevel, &active, &onlineVisible, &lastActiveVisible, &item.FollowingCount, &item.FollowerCount, &item.PostCount, &item.LikeCount, &item.Following, &item.Liked) + if err != nil { + return item, err + } + item.VIP = vip == 1 + if birthday.Valid { + item.Age = age(birthday.Time) + item.Birthday = birthday.Time.Format("2006-01-02") + } + item.Online = (id == viewerID || onlineVisible == 1) && active.Valid && time.Since(active.Time) < 15*time.Minute + if active.Valid && (id == viewerID || lastActiveVisible == 1) { + activeTime := active.Time + item.LastActiveAt = &activeTime + } + item.Tags = []string{} + tagRows, tagErr := a.db.QueryContext(r.Context(), `SELECT t.name FROM user_tags ut JOIN tags t ON t.id=ut.tag_id WHERE ut.user_id=? AND t.status=1 ORDER BY t.sort_order,t.id LIMIT 12`, id) + if tagErr == nil { + defer tagRows.Close() + for tagRows.Next() { + var name string + if tagRows.Scan(&name) == nil { + item.Tags = append(item.Tags, name) + } + } + } + return item, nil +} + +func (a *App) userProfile(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "invalid id") + return + } + who := current(r) + if id != who.ID { + var targetAllows, viewerInvisible int + _ = a.db.QueryRowContext(r.Context(), `SELECT allow_profile_visit_record FROM user_privacy_settings WHERE user_id=?`, id).Scan(&targetAllows) + _ = a.db.QueryRowContext(r.Context(), `SELECT invisible_visit FROM user_privacy_settings WHERE user_id=?`, who.ID).Scan(&viewerInvisible) + canHide := viewerInvisible == 1 && a.resolveMembershipEntitlements(r.Context(), who.ID).CanInvisibleVisit + if targetAllows == 1 && !canHide { + _, _ = a.db.ExecContext(r.Context(), `INSERT INTO profile_visits (viewer_user_id,target_user_id,source) VALUES (?,?,'profile')`, who.ID, id) + } + } + item, err := a.loadProfile(r, id, who.ID) + if err != nil { + fail(w, 404, 30001, "用户不存在") + return + } + reply(w, item) +} + +func (a *App) discover(w http.ResponseWriter, r *http.Request) { a.discoverList(w, r, false) } +func (a *App) nearby(w http.ResponseWriter, r *http.Request) { a.discoverList(w, r, true) } + +func (a *App) discoverList(w http.ResponseWriter, r *http.Request, byDistance bool) { + who := current(r) + gender, _ := strconv.Atoi(r.URL.Query().Get("gender")) + scope := r.URL.Query().Get("scope") + sortMode := r.URL.Query().Get("sort") + page, pageSize, offset := pageOptions(r) + var myLat, myLng sql.NullFloat64 + _ = a.db.QueryRowContext(r.Context(), `SELECT latitude,longitude FROM user_location_states WHERE user_id=?`, who.ID).Scan(&myLat, &myLng) + fromWhere := ` FROM users u JOIN user_profiles p ON p.user_id=u.id JOIN user_privacy_settings privacy ON privacy.user_id=u.id LEFT JOIN user_location_states l ON l.user_id=u.id + WHERE u.status=1 AND u.id<>? AND NOT EXISTS(SELECT 1 FROM user_blocks b WHERE (b.user_id=? AND b.blocked_user_id=u.id) OR (b.user_id=u.id AND b.blocked_user_id=?))` + filterArgs := []any{who.ID, who.ID, who.ID} + if scope == "following" { + fromWhere += ` AND EXISTS(SELECT 1 FROM user_follows mine WHERE mine.user_id=? AND mine.target_user_id=u.id)` + filterArgs = append(filterArgs, who.ID) + } + if gender > 0 { + fromWhere += ` AND p.gender=?` + filterArgs = append(filterArgs, gender) + } + if byDistance { + fromWhere += ` AND privacy.nearby_visible=1` + } + var total int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*)`+fromWhere, filterArgs...).Scan(&total); err != nil { + fail(w, 500, 50001, "查询推荐用户失败") + return + } + query := `SELECT u.id,u.public_id,u.is_test,p.nickname,p.avatar_url,p.cover_url,p.gender,p.birthday,COALESCE(p.height_cm,0),p.city_name,p.bio,p.is_vip,p.vip_level,p.last_active_at,l.latitude,l.longitude,privacy.distance_visible,privacy.online_visible,privacy.last_active_visible, + EXISTS(SELECT 1 FROM user_follows f WHERE f.user_id=? AND f.target_user_id=u.id),EXISTS(SELECT 1 FROM user_likes x WHERE x.user_id=? AND x.target_user_id=u.id) + ` + fromWhere + args := append([]any{who.ID, who.ID}, filterArgs...) + if byDistance && myLat.Valid && myLng.Valid { + // Sort before LIMIT/OFFSET so page two can never contain users closer + // than page one. Hidden or missing distances are placed last. + query += ` ORDER BY CASE WHEN privacy.distance_visible=1 THEN COALESCE(ST_Distance_Sphere(POINT(l.longitude,l.latitude),POINT(?,?)),1000000000000000) ELSE 1000000000000000 END ASC,p.last_active_at DESC,u.id DESC LIMIT ? OFFSET ?` + args = append(args, myLng.Float64, myLat.Float64) + } else if sortMode == "latest" || byDistance { + query += ` ORDER BY p.last_active_at DESC,u.id DESC LIMIT ? OFFSET ?` + } else { + query += ` ORDER BY COALESCE((SELECT MAX(plan.recommendation_weight) FROM subscriptions sub JOIN membership_plans plan ON plan.id=sub.plan_id WHERE sub.user_id=u.id AND sub.status=1 AND sub.started_at<=NOW(3) AND sub.expires_at>NOW(3)),0) DESC,p.last_active_at DESC LIMIT ? OFFSET ?` + } + args = append(args, pageSize, offset) + rows, err := a.db.QueryContext(r.Context(), query, args...) + if err != nil { + fail(w, 500, 50001, err.Error()) + return + } + defer rows.Close() + items := []profileView{} + for rows.Next() { + var item profileView + var birthday sql.NullTime + var active sql.NullTime + var lat, lng sql.NullFloat64 + var distanceVisible, onlineVisible, lastActiveVisible int + var vip int + if err := rows.Scan(&item.ID, &item.PublicID, &item.IsTest, &item.Nickname, &item.Avatar, &item.Cover, &item.Gender, &birthday, &item.Height, &item.City, &item.Bio, &vip, &item.VIPLevel, &active, &lat, &lng, &distanceVisible, &onlineVisible, &lastActiveVisible, &item.Following, &item.Liked); err != nil { + continue + } + item.VIP = vip == 1 + if birthday.Valid { + item.Age = age(birthday.Time) + } + item.Online = onlineVisible == 1 && active.Valid && time.Since(active.Time) < 15*time.Minute + if active.Valid && lastActiveVisible == 1 { + activeTime := active.Time + item.LastActiveAt = &activeTime + } + if distanceVisible == 1 && myLat.Valid && myLng.Valid && lat.Valid && lng.Valid { + item.Distance = haversine(myLat.Float64, myLng.Float64, lat.Float64, lng.Float64) + item.DistanceText = distanceText(item.Distance) + } + item.Tags = []string{} + items = append(items, item) + } + reply(w, map[string]any{"items": items, "total": total, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) +} + +func (a *App) updateLocation(w http.ResponseWriter, r *http.Request) { + var req struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + CityCode string `json:"cityCode"` + } + if err := decode(r, &req); err != nil || req.Latitude < -90 || req.Latitude > 90 || req.Longitude < -180 || req.Longitude > 180 { + fail(w, 400, 20001, "无效的位置") + return + } + if req.CityCode == "" { + req.CityCode = "310100" + } + _, err := a.db.ExecContext(r.Context(), `INSERT INTO user_location_states (user_id,city_code,location_cell,latitude,longitude,source) VALUES (?,?, 'wx4g',?,?,'gps') ON DUPLICATE KEY UPDATE city_code=VALUES(city_code),latitude=VALUES(latitude),longitude=VALUES(longitude),last_location_at=NOW(3),source='gps'`, current(r).ID, req.CityCode, req.Latitude, req.Longitude) + if err != nil { + fail(w, 500, 50001, "位置更新失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) follow(w http.ResponseWriter, r *http.Request) { + a.relationship(w, r, "user_follows", true) +} +func (a *App) unfollow(w http.ResponseWriter, r *http.Request) { + a.relationship(w, r, "user_follows", false) +} +func (a *App) likeUser(w http.ResponseWriter, r *http.Request) { + a.relationship(w, r, "user_likes", true) +} +func (a *App) unlikeUser(w http.ResponseWriter, r *http.Request) { + a.relationship(w, r, "user_likes", false) +} + +func (a *App) relationship(w http.ResponseWriter, r *http.Request, table string, create bool) { + target, err := pathID(r) + if err != nil || target == current(r).ID { + fail(w, 400, 20001, "无效用户") + return + } + if create { + var targetExists int + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM users u WHERE u.id=? AND u.status=1 AND u.deleted_at IS NULL AND NOT EXISTS(SELECT 1 FROM user_blocks b WHERE (b.user_id=? AND b.blocked_user_id=u.id) OR (b.user_id=u.id AND b.blocked_user_id=?)))`, target, current(r).ID, current(r).ID).Scan(&targetExists) + if targetExists != 1 { + fail(w, http.StatusNotFound, 30001, "用户不存在或不可操作") + return + } + if table == "user_likes" { + tx, beginErr := a.db.BeginTx(r.Context(), nil) + if beginErr != nil { + fail(w, 500, 50001, "操作失败") + return + } + defer func() { _ = tx.Rollback() }() + var exists int + _ = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM user_likes WHERE user_id=? AND target_user_id=?)`, current(r).ID, target).Scan(&exists) + if exists == 0 { + err = a.reserveDailyLike(tx, r, "user", target) + } + if err == nil { + _, err = tx.ExecContext(r.Context(), `INSERT IGNORE INTO user_likes (user_id,target_user_id,source) VALUES (?,?,'profile')`, current(r).ID, target) + } + if err == nil { + err = tx.Commit() + } + if limitErr, ok := err.(*dailyLikeLimitError); ok { + fail(w, http.StatusTooManyRequests, 20002, limitErr.Error()) + return + } + } else { + _, err = a.db.ExecContext(r.Context(), `INSERT IGNORE INTO user_follows (user_id,target_user_id) VALUES (?,?)`, current(r).ID, target) + } + } else { + _, err = a.db.ExecContext(r.Context(), `DELETE FROM `+table+` WHERE user_id=? AND target_user_id=?`, current(r).ID, target) + } + if err != nil { + fail(w, 500, 50001, "操作失败") + return + } + if create { + typ, title, content := "follow", "新的关注", current(r).Name+" 关注了你" + if table == "user_likes" { + typ, title, content = "like", "新的喜欢", current(r).Name+" 喜欢了你" + } + a.notifyUser(r.Context(), target, typ, title, content, "user", current(r).ID) + } + reply(w, map[string]bool{"success": true}) +} + +type postView struct { + ID int64 `json:"id"` + User profileView `json:"user"` + Content string `json:"content"` + Location string `json:"location"` + LikeCount int `json:"likeCount"` + CommentCount int `json:"commentCount"` + Liked bool `json:"liked"` + Media []string `json:"media"` + CreatedAt time.Time `json:"createdAt"` + CanEdit bool `json:"canEdit"` + CanDelete bool `json:"canDelete"` +} + +func (a *App) feed(w http.ResponseWriter, r *http.Request) { + who := current(r) + page, pageSize, offset := pageOptions(r) + scopeFollowing := r.URL.Query().Get("scope") == "following" + countQuery := `SELECT COUNT(*) FROM posts p JOIN users u ON u.id=p.user_id JOIN user_profiles pr ON pr.user_id=p.user_id WHERE p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id)))` + countArgs := []any{who.ID, who.ID} + if scopeFollowing { + countQuery += ` AND EXISTS(SELECT 1 FROM user_follows f WHERE f.user_id=? AND f.target_user_id=p.user_id)` + countArgs = append(countArgs, who.ID) + } + var total int + if err := a.db.QueryRowContext(r.Context(), countQuery, countArgs...).Scan(&total); err != nil { + fail(w, 500, 50001, "查询动态失败") + return + } + query := `SELECT p.id,p.user_id,p.content,p.location_text,p.like_count,p.comment_count,p.created_at,u.public_id,pr.nickname,pr.avatar_url,pr.gender,pr.is_vip,EXISTS(SELECT 1 FROM post_likes l WHERE l.post_id=p.id AND l.user_id=?) FROM posts p JOIN users u ON u.id=p.user_id JOIN user_profiles pr ON pr.user_id=p.user_id WHERE p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id)))` + args := []any{who.ID, who.ID, who.ID} + if scopeFollowing { + query += ` AND EXISTS(SELECT 1 FROM user_follows f WHERE f.user_id=? AND f.target_user_id=p.user_id)` + args = append(args, who.ID) + } + query += ` ORDER BY p.created_at DESC LIMIT ? OFFSET ?` + args = append(args, pageSize, offset) + rows, err := a.db.QueryContext(r.Context(), query, args...) + if err != nil { + fail(w, 500, 50001, err.Error()) + return + } + defer rows.Close() + items := []postView{} + for rows.Next() { + var item postView + var uid int64 + var vip int + if rows.Scan(&item.ID, &uid, &item.Content, &item.Location, &item.LikeCount, &item.CommentCount, &item.CreatedAt, &item.User.PublicID, &item.User.Nickname, &item.User.Avatar, &item.User.Gender, &vip, &item.Liked) != nil { + continue + } + item.User.ID = uid + item.User.VIP = vip == 1 + item.CanEdit = uid == who.ID + item.CanDelete = uid == who.ID + mediaRows, _ := a.db.QueryContext(r.Context(), `SELECT media_url FROM post_media WHERE post_id=? ORDER BY sort_order`, item.ID) + item.Media = []string{} + if mediaRows != nil { + for mediaRows.Next() { + var url string + _ = mediaRows.Scan(&url) + item.Media = append(item.Media, url) + } + _ = mediaRows.Close() + } + items = append(items, item) + } + reply(w, map[string]any{"items": items, "total": total, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) +} + +func (a *App) createPost(w http.ResponseWriter, r *http.Request) { + if a.isSanctionActive(r.Context(), current(r).ID, "CONTENT_LIMIT") { + fail(w, http.StatusForbidden, 10006, "账号处于内容发布限制期") + return + } + var req struct { + Content string `json:"content"` + Media []string `json:"media"` + Location string `json:"location"` + Visibility int `json:"visibility"` + } + if decode(r, &req) != nil { + fail(w, 400, 20001, "动态格式错误") + return + } + req.Content = strings.TrimSpace(req.Content) + req.Location = strings.TrimSpace(req.Location) + if (req.Content == "" && len(req.Media) == 0) || len([]rune(req.Content)) > 2000 || len(req.Media) > 9 || len([]rune(req.Location)) > 100 { + fail(w, 400, 20001, "动态需包含文字或图片,最多 9 张图片") + return + } + if req.Visibility != 2 { + req.Visibility = 1 + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "发布失败") + return + } + defer func() { _ = tx.Rollback() }() + for _, rawURL := range req.Media { + mediaURL := strings.TrimSpace(rawURL) + var exists int + if mediaURL == "" || tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM media_assets WHERE owner_user_id=? AND public_url=? AND status=1 AND moderation_status=1)`, current(r).ID, mediaURL).Scan(&exists) != nil || exists != 1 { + fail(w, 400, 20001, "动态图片必须由当前账号上传且通过安全检查") + return + } + } + result, err := tx.ExecContext(r.Context(), `INSERT INTO posts (user_id,content,visibility,city_code,location_text) VALUES (?,?,?,'310100',?)`, current(r).ID, req.Content, req.Visibility, req.Location) + if err != nil { + _ = tx.Rollback() + fail(w, 500, 50001, "发布失败") + return + } + id, _ := result.LastInsertId() + for i, url := range req.Media { + if _, err = tx.ExecContext(r.Context(), `INSERT INTO post_media(post_id,media_url,sort_order)VALUES(?,?,?)`, id, url, i); err != nil { + fail(w, 500, 50001, "发布失败") + return + } + } + if tx.Commit() != nil { + fail(w, 500, 50001, "发布失败") + return + } + reply(w, map[string]any{"id": id}) +} + +func (a *App) likePost(w http.ResponseWriter, r *http.Request) { a.postLike(w, r, true) } +func (a *App) unlikePost(w http.ResponseWriter, r *http.Request) { a.postLike(w, r, false) } +func (a *App) postLike(w http.ResponseWriter, r *http.Request, create bool) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "invalid id") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "点赞失败") + return + } + defer func() { _ = tx.Rollback() }() + var postExists int + if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM posts p WHERE p.id=? AND p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id))))`, id, current(r).ID, current(r).ID).Scan(&postExists); err != nil || postExists != 1 { + fail(w, http.StatusNotFound, 30001, "动态不存在") + return + } + if create { + var exists int + _ = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM post_likes WHERE post_id=? AND user_id=?)`, id, current(r).ID).Scan(&exists) + if exists == 0 { + if reserveErr := a.reserveDailyLike(tx, r, "post", id); reserveErr != nil { + _ = tx.Rollback() + if limitErr, ok := reserveErr.(*dailyLikeLimitError); ok { + fail(w, http.StatusTooManyRequests, 20002, limitErr.Error()) + return + } + fail(w, 500, 50001, "点赞失败") + return + } + } + result, execErr := tx.ExecContext(r.Context(), `INSERT IGNORE INTO post_likes(post_id,user_id)VALUES(?,?)`, id, current(r).ID) + if execErr != nil { + fail(w, 500, 50001, "点赞失败") + return + } + affected, _ := result.RowsAffected() + if affected > 0 { + if _, err = tx.ExecContext(r.Context(), `UPDATE posts SET like_count=like_count+1 WHERE id=?`, id); err != nil { + fail(w, 500, 50001, "点赞失败") + return + } + _, _ = tx.ExecContext(r.Context(), `INSERT INTO notifications(user_id,type,title,content,biz_type,biz_id) SELECT p.user_id,'like','动态获赞',?,'post',p.id FROM posts p LEFT JOIN user_notification_settings ns ON ns.user_id=p.user_id WHERE p.id=? AND p.user_id<>? AND COALESCE(ns.interaction_enabled,1)=1`, current(r).Name+" 赞了你的动态", id, current(r).ID) + } + } else { + result, execErr := tx.ExecContext(r.Context(), `DELETE FROM post_likes WHERE post_id=? AND user_id=?`, id, current(r).ID) + if execErr != nil { + fail(w, 500, 50001, "取消点赞失败") + return + } + affected, _ := result.RowsAffected() + if affected > 0 { + if _, err = tx.ExecContext(r.Context(), `UPDATE posts SET like_count=GREATEST(like_count-1,0) WHERE id=?`, id); err != nil { + fail(w, 500, 50001, "取消点赞失败") + return + } + } + } + if err = tx.Commit(); err != nil { + fail(w, 500, 50001, "保存点赞状态失败") + return + } + reply(w, map[string]bool{"success": true}) +} + +func (a *App) comments(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态编号无效") + return + } + if _, err = a.loadPost(r, id); err != nil { + fail(w, http.StatusNotFound, 30001, "动态不存在") + return + } + page, pageSize, offset := pageOptions(r) + var total int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM post_comments WHERE post_id=? AND status=1`, id).Scan(&total); err != nil { + fail(w, 500, 50001, "查询失败") + return + } + rows, err := a.db.QueryContext(r.Context(), `SELECT c.id,c.content,c.created_at,p.user_id,p.nickname,p.avatar_url,c.parent_comment_id,c.reply_user_id,COALESCE(reply.nickname,'') FROM post_comments c JOIN user_profiles p ON p.user_id=c.user_id LEFT JOIN user_profiles reply ON reply.user_id=c.reply_user_id WHERE c.post_id=? AND c.status=1 ORDER BY c.created_at LIMIT ? OFFSET ?`, id, pageSize, offset) + if err != nil { + fail(w, 500, 50001, "查询失败") + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var cid, uid int64 + var content, nick, avatar, replyNickname string + var parentID, replyUserID sql.NullInt64 + var created time.Time + _ = rows.Scan(&cid, &content, &created, &uid, &nick, &avatar, &parentID, &replyUserID, &replyNickname) + items = append(items, map[string]any{"id": cid, "content": content, "createdAt": created, "parentCommentId": nullableInt64(parentID), "replyUser": map[string]any{"id": nullableInt64(replyUserID), "nickname": replyNickname}, "canDelete": uid == current(r).ID, "user": map[string]any{"id": uid, "nickname": nick, "avatar": avatar}}) + } + reply(w, map[string]any{"items": items, "total": total, "page": page, "pageSize": pageSize, "hasMore": offset+len(items) < total}) +} + +func (a *App) createComment(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + fail(w, 400, 20001, "动态编号无效") + return + } + var req struct { + Content string `json:"content"` + ParentCommentID *int64 `json:"parentCommentId"` + ReplyUserID *int64 `json:"replyUserId"` + } + req.Content = strings.TrimSpace(req.Content) + if decode(r, &req) != nil { + fail(w, 400, 20001, "评论格式错误") + return + } + req.Content = strings.TrimSpace(req.Content) + if req.Content == "" || len([]rune(req.Content)) > 1000 { + fail(w, 400, 20001, "评论不能为空") + return + } + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + fail(w, 500, 50001, "评论失败") + return + } + defer func() { _ = tx.Rollback() }() + var postExists int + if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM posts p WHERE p.id=? AND p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id))))`, id, current(r).ID, current(r).ID).Scan(&postExists); err != nil || postExists != 1 { + fail(w, http.StatusNotFound, 30001, "动态不存在") + return + } + if req.ParentCommentID != nil { + var replyUserID int64 + if err := tx.QueryRowContext(r.Context(), `SELECT user_id FROM post_comments WHERE id=? AND post_id=? AND status=1`, *req.ParentCommentID, id).Scan(&replyUserID); err != nil { + _ = tx.Rollback() + fail(w, 400, 20001, "回复的评论不存在") + return + } + if req.ReplyUserID == nil { + req.ReplyUserID = &replyUserID + } + } + result, err := tx.ExecContext(r.Context(), `INSERT INTO post_comments(post_id,user_id,parent_comment_id,reply_user_id,content)VALUES(?,?,?,?,?)`, id, current(r).ID, req.ParentCommentID, req.ReplyUserID, req.Content) + if err != nil { + _ = tx.Rollback() + fail(w, 500, 50001, "评论失败") + return + } + if _, err = tx.ExecContext(r.Context(), `UPDATE posts SET comment_count=comment_count+1 WHERE id=?`, id); err != nil { + fail(w, 500, 50001, "评论失败") + return + } + _, _ = tx.ExecContext(r.Context(), `INSERT INTO notifications(user_id,type,title,content,biz_type,biz_id) SELECT p.user_id,'comment','新的评论',?,'post',p.id FROM posts p LEFT JOIN user_notification_settings ns ON ns.user_id=p.user_id WHERE p.id=? AND p.user_id<>? AND COALESCE(ns.interaction_enabled,1)=1`, current(r).Name+" 评论了你的动态", id, current(r).ID) + if err = tx.Commit(); err != nil { + fail(w, 500, 50001, "评论失败") + return + } + cid, _ := result.LastInsertId() + reply(w, map[string]any{"id": cid}) +} + +func (a *App) createReport(w http.ResponseWriter, r *http.Request) { + var req struct { + TargetType string `json:"targetType"` + TargetID int64 `json:"targetId"` + Reason string `json:"reason"` + Description string `json:"description"` + Evidence []string `json:"evidence"` + } + if decode(r, &req) != nil || req.TargetID == 0 { + fail(w, 400, 20001, "举报信息不完整") + return + } + req.TargetType = strings.ToLower(strings.TrimSpace(req.TargetType)) + req.Reason = strings.ToLower(strings.TrimSpace(req.Reason)) + req.Description = strings.TrimSpace(req.Description) + allowedTargets := map[string]bool{"user": true, "post": true, "comment": true, "message": true} + allowedReasons := map[string]bool{"fraud": true, "harassment": true, "pornography": true, "advertising": true, "violence": true, "minor_safety": true, "privacy": true, "other": true} + if !allowedTargets[req.TargetType] || !allowedReasons[req.Reason] || len([]rune(req.Description)) > 1000 || len(req.Evidence) > 6 { + fail(w, 400, 20001, "举报类型、原因或证据无效") + return + } + var targetExists int + switch req.TargetType { + case "user": + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM users WHERE id=? AND id<>? AND status=1 AND deleted_at IS NULL)`, req.TargetID, current(r).ID).Scan(&targetExists) + case "post": + if item, loadErr := a.loadPost(r, req.TargetID); loadErr == nil && item.User.ID != current(r).ID { + targetExists = 1 + } + case "comment": + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM post_comments c JOIN posts p ON p.id=c.post_id WHERE c.id=? AND c.status=1 AND c.user_id<>? AND p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id))))`, req.TargetID, current(r).ID, current(r).ID, current(r).ID).Scan(&targetExists) + case "message": + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM im_messages message JOIN im_conversation_members member ON member.conversation_id=message.conversation_id AND member.user_id=? AND member.status=1 WHERE message.id=? AND message.sender_id<>?)`, current(r).ID, req.TargetID, current(r).ID).Scan(&targetExists) + } + if targetExists != 1 { + fail(w, http.StatusNotFound, 30001, "举报目标不存在、不可访问或属于当前账号") + return + } + evidence, evidenceErr := a.validateOwnedImageEvidence(r.Context(), current(r).ID, req.Evidence) + if evidenceErr != nil { + fail(w, http.StatusBadRequest, 20001, evidenceErr.Error()) + return + } + var duplicate int + _ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM reports WHERE reporter_user_id=? AND target_type=? AND target_id=? AND status='PENDING' AND created_at>DATE_SUB(NOW(3),INTERVAL 24 HOUR))`, current(r).ID, req.TargetType, req.TargetID).Scan(&duplicate) + if duplicate == 1 { + fail(w, http.StatusConflict, 20001, "该内容已举报,请等待处理") + return + } + evidenceJSON, _ := json.Marshal(evidence) + result, err := a.db.ExecContext(r.Context(), `INSERT INTO reports(reporter_user_id,target_type,target_id,reason_code,description,evidence_json)VALUES(?,?,?,?,?,?)`, current(r).ID, req.TargetType, req.TargetID, req.Reason, req.Description, evidenceJSON) + if err != nil { + fail(w, 500, 50001, "提交失败") + return + } + id, _ := result.LastInsertId() + reply(w, map[string]any{"id": id, "status": "PENDING"}) +} + +func age(birthday time.Time) int { + now := time.Now() + years := now.Year() - birthday.Year() + if now.YearDay() < birthday.YearDay() { + years-- + } + return years +} +func haversine(lat1, lng1, lat2, lng2 float64) float64 { + const earth = 6371 + dlat := (lat2 - lat1) * math.Pi / 180 + dlng := (lng2 - lng1) * math.Pi / 180 + a := math.Sin(dlat/2)*math.Sin(dlat/2) + math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Sin(dlng/2)*math.Sin(dlng/2) + return earth * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) +} +func distanceText(value float64) string { + if value < 1 { + return fmt.Sprintf("%.2fkm", value) + } + return fmt.Sprintf("%.1fkm", value) +} +func sortProfilesByDistance(items []profileView) { + for i := 0; i < len(items); i++ { + for j := i + 1; j < len(items); j++ { + if items[j].Distance < items[i].Distance { + items[i], items[j] = items[j], items[i] + } + } + } +} +func jsonBytes(value any) []byte { data, _ := json.Marshal(value); return data } diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/storage_providers.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/storage_providers.go new file mode 100644 index 0000000..2089b48 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/storage_providers.go @@ -0,0 +1,258 @@ +package app + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + aliyunoss "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss" + aliyuncredentials "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials" + huaweiobs "github.com/huaweicloud/huaweicloud-sdk-go-obs/obs" + qiniuauth "github.com/qiniu/go-sdk/v7/auth/qbox" + qiniustorage "github.com/qiniu/go-sdk/v7/storage" + qiniucredentials "github.com/qiniu/go-sdk/v7/storagev2/credentials" + qiniuhttpclient "github.com/qiniu/go-sdk/v7/storagev2/http_client" + qiniuuploader "github.com/qiniu/go-sdk/v7/storagev2/uploader" + tencentcos "github.com/tencentyun/cos-go-sdk-v5" +) + +var ( + storageBucketPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{1,126}[A-Za-z0-9]$`) + storagePrefixPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9/_-]{0,119}$`) +) + +type mediaObjectStorage interface { + Put(context.Context, string, string, int64, io.Reader) error + Delete(context.Context, string) error + Bucket() string +} + +func storageProviderName(provider string) string { + switch provider { + case "local": + return "本地存储" + case "aliyun_oss": + return "阿里云 OSS" + case "tencent_cos": + return "腾讯云 COS" + case "qiniu": + return "七牛云存储" + case "huawei_obs": + return "华为云 OBS" + case "huawei_flexus": + return "华为云 Flexus 对象存储" + default: + return provider + } +} + +func parseStorageHTTPSURL(raw string, allowPath bool) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf("必须是无账号、查询参数和片段的有效 HTTPS 地址") + } + if !allowPath && strings.Trim(parsed.EscapedPath(), "/") != "" { + return nil, fmt.Errorf("地址不能包含路径") + } + return parsed, nil +} + +func validateLocalStorageDirectory(raw string) error { + directory := strings.TrimSpace(raw) + if directory == "" { + return fmt.Errorf("请填写本地存储目录") + } + abs, err := filepath.Abs(filepath.Clean(directory)) + if err != nil { + return fmt.Errorf("本地存储目录无效") + } + root := filepath.VolumeName(abs) + string(os.PathSeparator) + if strings.EqualFold(filepath.Clean(abs), filepath.Clean(root)) { + return fmt.Errorf("本地存储目录不能是磁盘根目录") + } + if info, statErr := os.Stat(abs); statErr == nil && !info.IsDir() { + return fmt.Errorf("本地存储目录指向了文件") + } else if statErr != nil && !os.IsNotExist(statErr) { + return fmt.Errorf("无法访问本地存储目录") + } + return nil +} + +func (a *App) validateStorageProviderConfig(ctx context.Context) error { + provider := a.configPlain(ctx, "storage.provider", "local") + providers := []string{"local", "aliyun_oss", "tencent_cos", "qiniu", "huawei_obs", "huawei_flexus"} + if !containsString(providers, provider) { + return fmt.Errorf("不支持的文件存储厂商 %q", provider) + } + for _, spec := range integrationSpecs["storage"] { + if !spec.Required || (len(spec.Providers) > 0 && !containsString(spec.Providers, provider)) { + continue + } + if strings.TrimSpace(a.configPlain(ctx, spec.Key, "")) == "" { + return fmt.Errorf("请填写%s", spec.Label) + } + } + prefix := strings.Trim(strings.TrimSpace(a.configPlain(ctx, "storage.object_prefix", "media")), "/") + if !storagePrefixPattern.MatchString(prefix) || strings.Contains(prefix, "//") || strings.Contains(prefix, "..") { + return fmt.Errorf("云端对象前缀格式无效") + } + + if provider == "local" { + if err := validateLocalStorageDirectory(a.configPlain(ctx, "storage.local.directory", a.config.MediaDir)); err != nil { + return err + } + baseURL := strings.TrimSpace(a.configPlain(ctx, "storage.local.public_base_url", "")) + if baseURL != "" { + parsed, err := url.Parse(baseURL) + if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return fmt.Errorf("本地公开访问地址无效") + } + if a.config.Environment == "production" && parsed.Scheme != "https" { + return fmt.Errorf("生产环境本地公开访问地址必须使用 HTTPS") + } + } + return nil + } + + keyPrefix := "storage." + provider + "." + if provider == "qiniu" { + if !storageBucketPattern.MatchString(a.configPlain(ctx, keyPrefix+"bucket", "")) { + return fmt.Errorf("七牛云空间名称格式无效") + } + } else { + if _, err := parseStorageHTTPSURL(a.configPlain(ctx, keyPrefix+"endpoint", ""), false); err != nil { + return fmt.Errorf("%s Endpoint %v", storageProviderName(provider), err) + } + if !storageBucketPattern.MatchString(a.configPlain(ctx, keyPrefix+"bucket", "")) { + return fmt.Errorf("%s Bucket 名称格式无效", storageProviderName(provider)) + } + } + if _, err := parseStorageHTTPSURL(a.configPlain(ctx, keyPrefix+"public_base_url", ""), true); err != nil { + return fmt.Errorf("%s文件访问域名%v", storageProviderName(provider), err) + } + return nil +} + +func storagePublicURL(baseURL, objectKey string) string { + return strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/" + strings.TrimLeft(objectKey, "/") +} + +func storageHTTPClient() *http.Client { + return &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +type aliyunOSSStorage struct { + bucket string + client *aliyunoss.Client +} + +func (s *aliyunOSSStorage) Bucket() string { return s.bucket } +func (s *aliyunOSSStorage) Put(ctx context.Context, key, contentType string, size int64, body io.Reader) error { + _, err := s.client.PutObject(ctx, &aliyunoss.PutObjectRequest{ + Bucket: aliyunoss.Ptr(s.bucket), Key: aliyunoss.Ptr(key), Body: body, + ContentType: aliyunoss.Ptr(contentType), ContentLength: aliyunoss.Ptr(size), + }) + return err +} +func (s *aliyunOSSStorage) Delete(ctx context.Context, key string) error { + _, err := s.client.DeleteObject(ctx, &aliyunoss.DeleteObjectRequest{Bucket: aliyunoss.Ptr(s.bucket), Key: aliyunoss.Ptr(key)}) + return err +} + +type tencentCOSStorage struct { + bucket string + client *tencentcos.Client +} + +func (s *tencentCOSStorage) Bucket() string { return s.bucket } +func (s *tencentCOSStorage) Put(ctx context.Context, key, contentType string, size int64, body io.Reader) error { + _, err := s.client.Object.Put(ctx, key, body, &tencentcos.ObjectPutOptions{ObjectPutHeaderOptions: &tencentcos.ObjectPutHeaderOptions{ContentType: contentType, ContentLength: size}}) + return err +} +func (s *tencentCOSStorage) Delete(ctx context.Context, key string) error { + _, err := s.client.Object.Delete(ctx, key) + return err +} + +type qiniuStorage struct { + bucket string + uploader *qiniuuploader.UploadManager + deleteMac *qiniuauth.Mac +} + +func (s *qiniuStorage) Bucket() string { return s.bucket } +func (s *qiniuStorage) Put(ctx context.Context, key, contentType string, _ int64, body io.Reader) error { + return s.uploader.UploadReader(ctx, body, &qiniuuploader.ObjectOptions{BucketName: s.bucket, ObjectName: &key, FileName: filepath.Base(key), ContentType: contentType}, nil) +} +func (s *qiniuStorage) Delete(_ context.Context, key string) error { + manager := qiniustorage.NewBucketManager(s.deleteMac, &qiniustorage.Config{UseHTTPS: true}) + return manager.Delete(s.bucket, key) +} + +type huaweiOBSStorage struct { + bucket string + client *huaweiobs.ObsClient +} + +func (s *huaweiOBSStorage) Bucket() string { return s.bucket } +func (s *huaweiOBSStorage) Put(_ context.Context, key, contentType string, size int64, body io.Reader) error { + _, err := s.client.PutObject(&huaweiobs.PutObjectInput{PutObjectBasicInput: huaweiobs.PutObjectBasicInput{ + ObjectOperationInput: huaweiobs.ObjectOperationInput{Bucket: s.bucket, Key: key}, + HttpHeader: huaweiobs.HttpHeader{ContentType: contentType}, ContentLength: size, + }, Body: body}) + return err +} +func (s *huaweiOBSStorage) Delete(_ context.Context, key string) error { + _, err := s.client.DeleteObject(&huaweiobs.DeleteObjectInput{Bucket: s.bucket, Key: key}) + return err +} + +func (a *App) newMediaObjectStorage(ctx context.Context) (mediaObjectStorage, func(), error) { + if err := a.validateStorageProviderConfig(ctx); err != nil { + return nil, func() {}, err + } + provider := a.configPlain(ctx, "storage.provider", "local") + keyPrefix := "storage." + provider + "." + switch provider { + case "aliyun_oss": + config := aliyunoss.LoadDefaultConfig(). + WithCredentialsProvider(aliyuncredentials.NewStaticCredentialsProvider(a.configPlain(ctx, keyPrefix+"access_key_id", ""), a.configPlain(ctx, keyPrefix+"access_key_secret", ""))). + WithRegion(a.configPlain(ctx, keyPrefix+"region", "")). + WithEndpoint(a.configPlain(ctx, keyPrefix+"endpoint", "")). + WithConnectTimeout(5 * time.Second). + WithReadWriteTimeout(30 * time.Second). + WithRetryMaxAttempts(3) + return &aliyunOSSStorage{bucket: a.configPlain(ctx, keyPrefix+"bucket", ""), client: aliyunoss.NewClient(config)}, func() {}, nil + case "tencent_cos": + endpoint, _ := url.Parse(a.configPlain(ctx, keyPrefix+"endpoint", "")) + client := tencentcos.NewClient(&tencentcos.BaseURL{BucketURL: endpoint}, &http.Client{ + Timeout: 30 * time.Second, + Transport: &tencentcos.AuthorizationTransport{SecretID: a.configPlain(ctx, keyPrefix+"secret_id", ""), SecretKey: a.configPlain(ctx, keyPrefix+"secret_key", "")}, + }) + return &tencentCOSStorage{bucket: a.configPlain(ctx, keyPrefix+"bucket", ""), client: client}, func() {}, nil + case "qiniu": + accessKey, secretKey := a.configPlain(ctx, keyPrefix+"access_key", ""), a.configPlain(ctx, keyPrefix+"secret_key", "") + manager := qiniuuploader.NewUploadManager(&qiniuuploader.UploadManagerOptions{Options: qiniuhttpclient.Options{Credentials: qiniucredentials.NewCredentials(accessKey, secretKey), BasicHTTPClient: storageHTTPClient()}, MultiPartsThreshold: 8 << 20, PartSize: 4 << 20, Concurrency: 2}) + return &qiniuStorage{bucket: a.configPlain(ctx, keyPrefix+"bucket", ""), uploader: manager, deleteMac: qiniuauth.NewMac(accessKey, secretKey)}, func() {}, nil + case "huawei_obs", "huawei_flexus": + client, err := huaweiobs.New(a.configPlain(ctx, keyPrefix+"access_key", ""), a.configPlain(ctx, keyPrefix+"secret_key", ""), a.configPlain(ctx, keyPrefix+"endpoint", ""), huaweiobs.WithConnectTimeout(5), huaweiobs.WithSocketTimeout(30), huaweiobs.WithMaxRetryCount(2)) + if err != nil { + return nil, func() {}, err + } + return &huaweiOBSStorage{bucket: a.configPlain(ctx, keyPrefix+"bucket", ""), client: client}, client.Close, nil + default: + return nil, func() {}, nil + } +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/storage_providers_test.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/storage_providers_test.go new file mode 100644 index 0000000..4bbd4ad --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/storage_providers_test.go @@ -0,0 +1,44 @@ +package app + +import ( + "path/filepath" + "testing" +) + +func TestParseStorageHTTPSURL(t *testing.T) { + tests := []struct { + url string + allowPath bool + valid bool + }{ + {url: "https://oss-cn-hangzhou.aliyuncs.com", valid: true}, + {url: "https://cdn.example.com/media", allowPath: true, valid: true}, + {url: "http://oss.example.com", valid: false}, + {url: "https://user:pass@oss.example.com", valid: false}, + {url: "https://oss.example.com/path", valid: false}, + {url: "https://oss.example.com?token=secret", valid: false}, + } + for _, test := range tests { + _, err := parseStorageHTTPSURL(test.url, test.allowPath) + if (err == nil) != test.valid { + t.Errorf("parseStorageHTTPSURL(%q, %v) error = %v, valid = %v", test.url, test.allowPath, err, test.valid) + } + } +} + +func TestValidateLocalStorageDirectoryRejectsRoot(t *testing.T) { + root := filepath.VolumeName(t.TempDir()) + string(filepath.Separator) + if err := validateLocalStorageDirectory(root); err == nil { + t.Fatalf("expected volume root %q to be rejected", root) + } + if err := validateLocalStorageDirectory(filepath.Join(t.TempDir(), "uploads")); err != nil { + t.Fatalf("expected nested upload directory to be accepted: %v", err) + } +} + +func TestStoragePublicURL(t *testing.T) { + got := storagePublicURL("https://cdn.example.com/media/", "/2026/08/test.png") + if got != "https://cdn.example.com/media/2026/08/test.png" { + t.Fatalf("unexpected public URL: %s", got) + } +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/test_users.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/test_users.go new file mode 100644 index 0000000..2617594 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/test_users.go @@ -0,0 +1,29 @@ +package app + +import ( + "fmt" + "strings" +) + +func testUserFilter(userType, batch string) (string, []any, error) { + where := "" + args := []any{} + switch userType { + case "": + case "test": + where = " AND u.is_test=1" + case "registered": + where = " AND u.is_test=0" + default: + return "", nil, fmt.Errorf("用户类型筛选无效") + } + batch = strings.TrimSpace(batch) + if len(batch) > 64 { + return "", nil, fmt.Errorf("测试批次长度不能超过64位") + } + if batch != "" { + where += " AND u.is_test=1 AND u.test_batch=?" + args = append(args, batch) + } + return where, args, nil +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/test_users_test.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/test_users_test.go new file mode 100644 index 0000000..c5addd7 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/test_users_test.go @@ -0,0 +1,41 @@ +package app + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestUserTypeFilters(t *testing.T) { + for _, item := range []struct{ kind, expected string }{{"", ""}, {"test", " AND u.is_test=1"}, {"registered", " AND u.is_test=0"}} { + where, args, err := testUserFilter(item.kind, "") + if err != nil || where != item.expected || len(args) != 0 { + t.Fatalf("filter %q: %q %v %v", item.kind, where, args, err) + } + } + if _, _, err := testUserFilter("invalid", ""); err == nil { + t.Fatal("invalid type accepted") + } + if _, _, err := testUserFilter("", strings.Repeat("x", 65)); err == nil { + t.Fatal("oversized batch accepted") + } + batch := "' OR 1=1 --" + where, args, err := testUserFilter("test", batch) + if err != nil || strings.Contains(where, batch) || !strings.Contains(where, "u.test_batch=?") || len(args) != 1 || args[0] != batch { + t.Fatal("batch must be an exact bound SQL value") + } +} + +func TestProfileExposesExplicitTestFlag(t *testing.T) { + for _, isTest := range []bool{false, true} { + data, err := json.Marshal(profileView{IsTest: isTest}) + if err != nil { + t.Fatal(err) + } + var result map[string]any + _ = json.Unmarshal(data, &result) + if result["isTest"] != isTest { + t.Fatalf("test label lost in profile JSON: %s", data) + } + } +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/app/user_oauth.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/user_oauth.go new file mode 100644 index 0000000..232e22d --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/app/user_oauth.go @@ -0,0 +1,401 @@ +package app + +import ( + "context" + "database/sql" + "errors" + "net/http" + "net/url" + "strings" + "time" +) + +type userOAuthLoginCode struct { + Provider string + Platform string + IdentityScope string + AppProofHash []byte + Subject string + Email string + DisplayName string + AvatarURL string + UserID sql.NullInt64 +} + +func (a *App) userOAuthFrontendURL(ctx context.Context) (string, error) { + raw := strings.TrimSpace(a.configPlain(ctx, "oauth.user.frontend_callback_url", "")) + if raw == "" { + return "", errors.New("客户端第三方登录结果页未配置") + } + if err := a.validateAdminOAuthRedirectURL(raw); err != nil { + return "", err + } + return raw, nil +} + +func (a *App) enabledUserOAuthProviders(ctx context.Context) ([]adminOAuthProvider, error) { + providers := make([]adminOAuthProvider, 0, len(adminOAuthProviderNames)) + for _, code := range []string{"wechat", "qq", "github", "google"} { + if !a.configBool(ctx, "oauth.user."+code+".enabled", false) { + continue + } + provider, err := a.adminOAuthProvider(ctx, code) + if err != nil { + return nil, err + } + providers = append(providers, provider) + } + return providers, nil +} + +// oauthCallback lets one provider callback URL safely serve both the admin +// console and the uni-app H5 client. The random state value selects the +// audience; it is never accepted by both state tables. +func (a *App) oauthCallback(w http.ResponseWriter, r *http.Request) { + state := strings.TrimSpace(r.URL.Query().Get("state")) + if state != "" { + var exists int + if a.db.QueryRowContext(r.Context(), `SELECT 1 FROM user_oauth_states WHERE state_hash=?`, oauthHash(state)).Scan(&exists) == nil { + a.userOAuthCallback(w, r) + return + } + if a.db.QueryRowContext(r.Context(), `SELECT 1 FROM admin_oauth_states WHERE state_hash=?`, oauthHash(state)).Scan(&exists) == nil { + a.adminOAuthCallback(w, r) + return + } + } + if strings.HasPrefix(r.URL.Path, "/api/") { + a.userOAuthCallback(w, r) + return + } + a.adminOAuthCallback(w, r) +} + +func (a *App) userOAuthProviders(w http.ResponseWriter, r *http.Request) { + platform, err := oauthClientPlatform(r.URL.Query().Get("platform")) + if err != nil { + fail(w, 400, 20001, err.Error()) + return + } + items := make([]map[string]string, 0, len(adminOAuthProviderNames)) + for _, code := range []string{"wechat", "qq", "github", "google"} { + if !a.configBool(r.Context(), userOAuthEnabledKey(platform, code), false) { + continue + } + var provider adminOAuthProvider + var err error + if platform == "app" { + provider, err = a.appOAuthProvider(r.Context(), code) + } else { + provider, err = a.adminOAuthProvider(r.Context(), code) + } + if err != nil { + continue + } + items = append(items, map[string]string{"code": provider.Code, "name": provider.Name}) + } + w.Header().Set("Cache-Control", "no-store") + reply(w, map[string]any{"items": items}) +} + +func (a *App) userOAuthStart(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + Platform string `json:"platform"` + } + if decode(r, &req) != nil { + fail(w, http.StatusBadRequest, 20001, "请选择第三方登录渠道") + return + } + req.Provider = strings.ToLower(strings.TrimSpace(req.Provider)) + platform, err := oauthClientPlatform(req.Platform) + if err != nil { + fail(w, 400, 20001, err.Error()) + return + } + if platform == "app" && req.Provider != "github" { + fail(w, 400, 20001, "该 App 渠道需使用原生 SDK 授权") + return + } + if !a.rateLimit(w, r, "user_oauth_start", clientIP(r), 30, 10*time.Minute) { + return + } + if !a.configBool(r.Context(), userOAuthEnabledKey(platform, req.Provider), false) { + fail(w, http.StatusBadRequest, 20001, "该客户端登录方式未启用") + return + } + provider, err := a.adminOAuthProvider(r.Context(), req.Provider) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "该登录方式配置不完整") + return + } + if platform == "app" { + _, err = a.appOAuthFrontendURL(r.Context()) + } else { + _, err = a.userOAuthFrontendURL(r.Context()) + } + if err != nil { + fail(w, http.StatusBadRequest, 20001, "客户端登录结果页配置不完整") + return + } + state := randomToken() + verifier := "" + if provider.Code == "github" || provider.Code == "google" { + verifier = randomToken() + randomToken() + } + appProof := "" + var proofHash []byte + if platform == "app" { + appProof = randomToken() + proofHash = oauthHash(appProof) + } + _, err = a.db.ExecContext(r.Context(), `INSERT INTO user_oauth_states(state_hash,provider,code_verifier,expires_at,client_platform,app_proof_hash) VALUES(?,?,?,?,?,?)`, oauthHash(state), provider.Code, verifier, time.Now().Add(adminOAuthStateTTL), platform, proofHash) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "创建第三方登录请求失败") + return + } + a.cleanupUserOAuthRecords(r.Context()) + + authorizationURL, _ := url.Parse(provider.AuthorizationURL) + query := authorizationURL.Query() + if provider.Code == "wechat" { + query.Set("appid", provider.ClientID) + } else { + query.Set("client_id", provider.ClientID) + } + query.Set("redirect_uri", provider.RedirectURI) + query.Set("response_type", "code") + query.Set("scope", provider.Scope) + query.Set("state", state) + if verifier != "" { + query.Set("code_challenge", pkceChallenge(verifier)) + query.Set("code_challenge_method", "S256") + } + authorizationURL.RawQuery = query.Encode() + if provider.Code == "wechat" { + authorizationURL.Fragment = "wechat_redirect" + } + w.Header().Set("Cache-Control", "no-store") + response := map[string]string{"authorizationUrl": authorizationURL.String(), "provider": provider.Code} + if platform == "app" { + response["appProof"] = appProof + response["requestId"] = state + response["callbackUrl"] = appOAuthCallbackURL + } + reply(w, response) +} + +func (a *App) userOAuthCallback(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + state := strings.TrimSpace(r.URL.Query().Get("state")) + var providerCode, verifier, platform string + var proofHash []byte + err := a.db.QueryRowContext(r.Context(), `SELECT provider,code_verifier,client_platform,app_proof_hash FROM user_oauth_states WHERE state_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(state)).Scan(&providerCode, &verifier, &platform, &proofHash) + if err != nil { + fail(w, 400, 20001, "登录状态无效或已过期,请返回客户端重试") + return + } + var frontendURL string + if platform == "app" { + frontendURL, err = a.appOAuthFrontendURL(r.Context()) + if err == nil { + frontendURL += "?requestId=" + url.QueryEscape(state) + } + } else { + frontendURL, err = a.userOAuthFrontendURL(r.Context()) + } + if err != nil { + fail(w, 503, 50001, "客户端第三方登录回调未配置") + return + } + result, err := a.db.ExecContext(r.Context(), `UPDATE user_oauth_states SET used_at=NOW(3) WHERE state_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(state)) + if err != nil { + a.redirectUserOAuthResult(w, r, frontendURL, "", "第三方登录处理失败") + return + } + affected, _ := result.RowsAffected() + if affected != 1 { + a.redirectUserOAuthResult(w, r, frontendURL, "", "登录状态已被使用") + return + } + if strings.TrimSpace(r.URL.Query().Get("error")) != "" { + a.redirectUserOAuthResult(w, r, frontendURL, "", "第三方授权已取消或失败") + return + } + code := strings.TrimSpace(r.URL.Query().Get("code")) + if code == "" { + a.redirectUserOAuthResult(w, r, frontendURL, "", "第三方平台未返回授权码") + return + } + if !a.configBool(r.Context(), userOAuthEnabledKey(platform, providerCode), false) { + a.redirectUserOAuthResult(w, r, frontendURL, "", "该客户端登录方式已停用") + return + } + provider, err := a.adminOAuthProvider(r.Context(), providerCode) + if err != nil { + a.redirectUserOAuthResult(w, r, frontendURL, "", "该登录方式配置不可用") + return + } + identity, err := a.fetchAdminOAuthIdentity(r.Context(), provider, code, verifier) + if err != nil { + a.redirectUserOAuthResult(w, r, frontendURL, "", "获取第三方账号信息失败") + return + } + loginCode, err := a.issueUserOAuthCode(r.Context(), provider.Code, platform, "", proofHash, identity) + if err != nil { + a.redirectUserOAuthResult(w, r, frontendURL, "", "创建登录凭证失败") + return + } + a.redirectUserOAuthResult(w, r, frontendURL, loginCode, "") +} + +func (a *App) redirectUserOAuthResult(w http.ResponseWriter, r *http.Request, frontendURL, code, message string) { + target, err := url.Parse(frontendURL) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "客户端登录结果页地址无效") + return + } + query := target.Query() + if code != "" { + query.Set("oauthCode", code) + } else { + query.Set("oauthError", message) + } + target.RawQuery = query.Encode() + http.Redirect(w, r, target.String(), http.StatusFound) +} + +func (a *App) userOAuthExchange(w http.ResponseWriter, r *http.Request) { + var req struct { + Code string `json:"code"` + DeviceID string `json:"deviceId"` + AppProof string `json:"appProof"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效") + return + } + if !a.rateLimit(w, r, "user_oauth_exchange", clientIP(r), 20, 10*time.Minute) { + return + } + loginCode, err := a.readUserOAuthLoginCode(r.Context(), strings.TrimSpace(req.Code)) + if err != nil { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效或已过期") + return + } + if err = a.validateUserOAuthCode(r.Context(), loginCode, req.AppProof); err != nil { + fail(w, 400, 20001, err.Error()) + return + } + if !loginCode.UserID.Valid { + reply(w, map[string]any{ + "requiresLink": true, + "provider": loginCode.Provider, + "providerName": adminOAuthProviderNames[loginCode.Provider], + "displayName": loginCode.DisplayName, + "avatarUrl": loginCode.AvatarURL, + }) + return + } + userID, nickname, err := a.consumeUserOAuthCode(r.Context(), strings.TrimSpace(req.Code), loginCode.UserID.Int64) + if err != nil { + fail(w, http.StatusUnauthorized, 10001, err.Error()) + return + } + a.finishLogin(w, r, userID, nickname, req.DeviceID) +} + +func (a *App) userOAuthLink(w http.ResponseWriter, r *http.Request) { + var req struct { + Code string `json:"code"` + Phone string `json:"phone"` + SMSCode string `json:"smsCode"` + DeviceID string `json:"deviceId"` + AppProof string `json:"appProof"` + } + if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" || !validPhone(req.Phone) || len(req.SMSCode) != 6 { + fail(w, http.StatusBadRequest, 20001, "请输入已注册手机号和正确的短信验证码") + return + } + phone := strings.TrimSpace(req.Phone) + if !a.rateLimit(w, r, "user_oauth_link_ip", clientIP(r), 10, 15*time.Minute) || !a.rateLimit(w, r, "user_oauth_link_phone", phone, 10, 15*time.Minute) { + return + } + loginCode, err := a.readUserOAuthLoginCode(r.Context(), strings.TrimSpace(req.Code)) + if err != nil || loginCode.UserID.Valid { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证无效、已绑定或已过期") + return + } + if err = a.validateUserOAuthCode(r.Context(), loginCode, req.AppProof); err != nil { + fail(w, 400, 20001, err.Error()) + return + } + var userID int64 + var nickname string + var status int + err = a.db.QueryRowContext(r.Context(), `SELECT u.id,p.nickname,u.status FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.phone_hash=? AND u.deleted_at IS NULL`, phoneHash(phone)).Scan(&userID, &nickname, &status) + if err != nil || status != 1 { + fail(w, http.StatusBadRequest, 20001, "手机号未注册或账号当前不可用") + return + } + if !a.consumeSMSCode(r, phone, "login", req.SMSCode) { + fail(w, http.StatusBadRequest, 20001, "验证码错误或已过期") + return + } + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + defer func() { _ = tx.Rollback() }() + result, err := tx.ExecContext(r.Context(), `UPDATE user_oauth_login_codes SET used_at=NOW(3),user_id=? WHERE code_hash=? AND user_id IS NULL AND used_at IS NULL AND expires_at>NOW(3)`, userID, oauthHash(strings.TrimSpace(req.Code))) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + affected, _ := result.RowsAffected() + if affected != 1 { + fail(w, http.StatusBadRequest, 20001, "第三方登录凭证已被使用") + return + } + _, err = tx.ExecContext(r.Context(), `INSERT INTO user_oauth_identities(provider,subject,user_id,email,display_name,avatar_url,identity_scope,last_login_at) VALUES(?,?,?,?,?,?,?,NOW(3))`, loginCode.Provider, loginCode.Subject, userID, loginCode.Email, loginCode.DisplayName, loginCode.AvatarURL, loginCode.IdentityScope) + if err != nil { + fail(w, http.StatusConflict, 20001, "该第三方账号或手机号已绑定此渠道") + return + } + if err = tx.Commit(); err != nil { + fail(w, http.StatusInternalServerError, 50001, "绑定第三方账号失败") + return + } + a.finishLogin(w, r, userID, nickname, req.DeviceID) +} + +func (a *App) readUserOAuthLoginCode(ctx context.Context, code string) (userOAuthLoginCode, error) { + var result userOAuthLoginCode + err := a.db.QueryRowContext(ctx, `SELECT provider,subject,email,display_name,avatar_url,user_id,client_platform,identity_scope,app_proof_hash FROM user_oauth_login_codes WHERE code_hash=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(code)).Scan(&result.Provider, &result.Subject, &result.Email, &result.DisplayName, &result.AvatarURL, &result.UserID, &result.Platform, &result.IdentityScope, &result.AppProofHash) + return result, err +} + +func (a *App) consumeUserOAuthCode(ctx context.Context, code string, userID int64) (int64, string, error) { + var nickname string + var status int + if err := a.db.QueryRowContext(ctx, `SELECT p.nickname,u.status FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.id=? AND u.deleted_at IS NULL`, userID).Scan(&nickname, &status); err != nil || status != 1 { + return 0, "", errors.New("账号不存在或当前不可用") + } + result, err := a.db.ExecContext(ctx, `UPDATE user_oauth_login_codes SET used_at=NOW(3) WHERE code_hash=? AND user_id=? AND used_at IS NULL AND expires_at>NOW(3)`, oauthHash(code), userID) + if err != nil { + return 0, "", errors.New("第三方登录处理失败") + } + affected, _ := result.RowsAffected() + if affected != 1 { + return 0, "", errors.New("第三方登录凭证无效或已使用") + } + _, _ = a.db.ExecContext(ctx, `UPDATE user_oauth_identities SET last_login_at=NOW(3) WHERE user_id=?`, userID) + return userID, nickname, nil +} + +func (a *App) cleanupUserOAuthRecords(ctx context.Context) { + _, _ = a.db.ExecContext(ctx, `DELETE FROM user_oauth_states WHERE expires_at 50 || len([]rune(documentNumber)) < 6 || len([]rune(documentNumber)) > 80 { + fail(w, http.StatusBadRequest, 20001, "姓名或证件号码格式无效") + return + } + if len(req.Evidence) == 0 || len(req.Evidence) > 3 { + fail(w, http.StatusBadRequest, 20001, "请上传 1 至 3 张认证材料") + return + } + + tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + fail(w, http.StatusInternalServerError, 50001, "提交认证失败") + return + } + defer func() { _ = tx.Rollback() }() + var existingStatus string + err = tx.QueryRowContext(r.Context(), `SELECT status FROM user_verifications WHERE user_id=? AND verification_type=? FOR UPDATE`, current(r).ID, req.Type).Scan(&existingStatus) + if err != nil && err != sql.ErrNoRows { + fail(w, http.StatusInternalServerError, 50001, "读取认证状态失败") + return + } + if existingStatus == "PENDING" { + fail(w, http.StatusConflict, 20001, "认证资料正在审核,请勿重复提交") + return + } + if existingStatus == "VERIFIED" { + fail(w, http.StatusConflict, 20001, "账号已完成认证") + return + } + + evidence := make([]string, 0, len(req.Evidence)) + seen := map[string]bool{} + for _, rawURL := range req.Evidence { + mediaURL := strings.TrimSpace(rawURL) + if mediaURL == "" || seen[mediaURL] { + fail(w, http.StatusBadRequest, 20001, "认证材料地址无效或重复") + return + } + seen[mediaURL] = true + var exists int + if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM media_assets WHERE owner_user_id=? AND public_url=? AND media_type='image' AND status=1)`, current(r).ID, mediaURL).Scan(&exists); err != nil || exists != 1 { + fail(w, http.StatusBadRequest, 20001, "认证材料必须由当前账号上传") + return + } + evidence = append(evidence, mediaURL) + } + evidenceJSON, _ := json.Marshal(evidence) + _, err = tx.ExecContext(r.Context(), `INSERT INTO user_verifications(user_id,verification_type,status,real_name,document_mask,document_hash,evidence_json,remark,reviewer_admin_id,submitted_at,reviewed_at) + VALUES(?,?,'PENDING',?,?,?,?, '',NULL,NOW(3),NULL) + ON DUPLICATE KEY UPDATE verification_type=VALUES(verification_type),status='PENDING',real_name=VALUES(real_name),document_mask=VALUES(document_mask),document_hash=VALUES(document_hash),evidence_json=VALUES(evidence_json),remark='',reviewer_admin_id=NULL,submitted_at=NOW(3),reviewed_at=NULL`, current(r).ID, req.Type, req.RealName, maskDocumentNumber(documentNumber), a.verificationDocumentHash(documentNumber), string(evidenceJSON)) + if err != nil || tx.Commit() != nil { + fail(w, http.StatusInternalServerError, 50001, "提交认证失败") + return + } + reply(w, map[string]any{"status": "PENDING", "submitted": true}) +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/testusers/assets.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/testusers/assets.go new file mode 100644 index 0000000..2ecff98 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/testusers/assets.go @@ -0,0 +1,57 @@ +package testusers + +import ( + "bytes" + "fmt" + "image/png" + "os" + "path/filepath" +) + +// CopyAvatars validates all sources and destination collisions before copying. +// Existing identical assets are reused. Existing different files are not replaced. +func CopyAvatars(sourceDir, mediaDir string) error { + if sourceDir == "" || mediaDir == "" { + return fmt.Errorf("both avatar source and media directories are required") + } + assets := make(map[string][]byte) + for _, name := range Avatars() { + data, err := os.ReadFile(filepath.Join(sourceDir, name)) + if err != nil { + return err + } + cfg, err := png.DecodeConfig(bytes.NewReader(data)) + if err != nil || cfg.Width < 256 || cfg.Height < 256 { + return fmt.Errorf("invalid or undersized PNG avatar: %s", name) + } + existing, err := os.ReadFile(filepath.Join(mediaDir, name)) + if err == nil { + if !bytes.Equal(existing, data) { + return fmt.Errorf("refusing to overwrite different existing avatar: %s", name) + } + continue + } + if !os.IsNotExist(err) { + return err + } + assets[name] = data + } + if err := os.MkdirAll(mediaDir, 0755); err != nil { + return err + } + for name, data := range assets { + file, err := os.OpenFile(filepath.Join(mediaDir, name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) + if err != nil { + return err + } + _, writeErr := file.Write(data) + closeErr := file.Close() + if writeErr != nil { + return writeErr + } + if closeErr != nil { + return closeErr + } + } + return nil +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/testusers/fixtures.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/testusers/fixtures.go new file mode 100644 index 0000000..acd4b74 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/testusers/fixtures.go @@ -0,0 +1,75 @@ +// Package testusers creates explicitly labelled, non-loginable test profiles. +// It is only used by an operator-invoked CLI, never during server startup. +package testusers + +import ( + "fmt" + "net/url" + "strings" +) + +const Batch = "cn-adults-20260831-v1" +const Count = 100 + +type Profile struct { + PublicID string `json:"publicId"` + IsTest bool `json:"isTest"` + TestBatch string `json:"testBatch"` + Label string `json:"label"` + Nickname string `json:"nickname"` + Gender int `json:"gender"` + Birthday string `json:"birthday"` + Height int `json:"height"` + CityCode string `json:"cityCode"` + City string `json:"city"` + Occupation string `json:"occupation"` + Bio string `json:"bio"` + AvatarFile string `json:"avatarFile"` + Avatar string `json:"avatar"` +} + +// Avatars contains five independently generated adult portraits per gender. +// Reuse is intentional for fixtures; these are not 100 real identities. +func Avatars() []string { + files := make([]string, 0, 10) + for gender := 1; gender <= 2; gender++ { + for n := 1; n <= 5; n++ { + files = append(files, fmt.Sprintf("900028%d%d-1.png", gender, n)) + } + } + return files +} + +func Generate(publicBase string) ([]Profile, error) { + base, err := url.Parse(strings.TrimRight(publicBase, "/")) + if err != nil || base.Host == "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" || (base.Scheme != "http" && base.Scheme != "https") { + return nil, fmt.Errorf("public base must be an absolute HTTP(S) uploads URL without credentials, query or fragment") + } + if base.Scheme == "http" && base.Hostname() != "127.0.0.1" && base.Hostname() != "localhost" && base.Hostname() != "::1" { + return nil, fmt.Errorf("non-local avatar URLs require HTTPS") + } + surnames := []string{"陈", "林", "周", "许", "苏", "沈", "陆", "顾", "方", "季"} + given := [][]string{{"沐川", "知远", "星河", "景行", "予安"}, {"晚晴", "知夏", "语桐", "清禾", "若宁"}} + cities := [][2]string{{"310100", "上海"}, {"440100", "广州"}, {"440300", "深圳"}, {"330100", "杭州"}, {"510100", "成都"}, {"420100", "武汉"}, {"320100", "南京"}, {"350200", "厦门"}, {"610100", "西安"}, {"370200", "青岛"}} + jobs := []string{"设计师", "工程师", "教师", "摄影师", "产品经理"} + hobbies := []string{"摄影与城市漫步", "阅读与咖啡", "跑步与音乐", "旅行与美食", "电影与绘画"} + ages := [2][5]int{{27, 30, 24, 33, 28}, {26, 29, 24, 32, 27}} + items := make([]Profile, 0, Count) + for gender := 1; gender <= 2; gender++ { + for i := 0; i < 50; i++ { + avatarFile := fmt.Sprintf("900028%d%d-1.png", gender, i%5+1) + city := cities[i%len(cities)] + items = append(items, Profile{ + PublicID: fmt.Sprintf("TESTCN%06d", (gender-1)*50+i+1), + IsTest: true, TestBatch: Batch, Label: "测试用户", + Nickname: "测试·" + surnames[i/5] + given[gender-1][i%5], + Gender: gender, Birthday: fmt.Sprintf("%d-%02d-%02d", 2026-ages[gender-1][i%5], i%6+1, i%27+1), + Height: 160 + (2-gender)*12 + i%12, + CityCode: city[0], City: city[1], Occupation: jobs[i%len(jobs)], + Bio: "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:" + hobbies[i%len(hobbies)] + "。", + AvatarFile: avatarFile, Avatar: base.String() + "/" + avatarFile, + }) + } + } + return items, nil +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/testusers/fixtures_test.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/testusers/fixtures_test.go new file mode 100644 index 0000000..568634a --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/testusers/fixtures_test.go @@ -0,0 +1,98 @@ +package testusers + +import ( + "bytes" + "image" + "image/png" + "os" + "path/filepath" + "reflect" + "regexp" + "strings" + "testing" + "time" +) + +func TestGenerateBalancedLabelledAdults(t *testing.T) { + items, err := Generate("https://im.bchongw.com/uploads/") + if err != nil || len(items) != 100 { + t.Fatalf("len=%d err=%v", len(items), err) + } + ids, names := map[string]bool{}, map[string]bool{} + counts := map[int]int{} + files := map[string]bool{} + for _, file := range Avatars() { + files[file] = true + } + for _, p := range items { + if ids[p.PublicID] || names[p.Nickname] || !p.IsTest || p.TestBatch != Batch || p.Label != "测试用户" || !strings.HasPrefix(p.Nickname, "测试·") { + t.Fatalf("missing label or duplicate identity: %+v", p) + } + ids[p.PublicID], names[p.Nickname] = true, true + counts[p.Gender]++ + birthday, err := time.Parse("2006-01-02", p.Birthday) + if err != nil || birthday.After(time.Date(2008, 8, 31, 0, 0, 0, 0, time.UTC)) { + t.Fatalf("invalid adult birthday: %s", p.Birthday) + } + if !files[p.AvatarFile] || p.Avatar != "https://im.bchongw.com/uploads/"+p.AvatarFile || !regexp.MustCompile(`^[0-9]+-[0-9]+\.png$`).MatchString(p.AvatarFile) { + t.Fatalf("avatar incompatible with media route: %s", p.Avatar) + } + if !strings.Contains(p.Bio, "非真实交友用户") { + t.Fatal("missing synthetic identity disclosure") + } + } + if counts[1] != 50 || counts[2] != 50 || len(counts) != 2 { + t.Fatalf("gender distribution: %v", counts) + } + again, _ := Generate("https://im.bchongw.com/uploads/") + if !reflect.DeepEqual(items, again) { + t.Fatal("fixture generation must be deterministic") + } +} + +func TestRejectUnsafePublicBase(t *testing.T) { + for _, base := range []string{"", "/uploads", "http://example.com/uploads", "https://user:secret@example.com/uploads", "https://example.com/uploads?x=1", "https://example.com/#bad", "javascript:alert(1)"} { + if _, err := Generate(base); err == nil { + t.Errorf("accepted unsafe base %q", base) + } + } + for _, base := range []string{"https://example.com/uploads", "http://127.0.0.1:8888/uploads", "http://localhost:8888/uploads"} { + if _, err := Generate(base); err != nil { + t.Errorf("rejected valid base %q: %v", base, err) + } + } +} + +func TestAvatarCopyIsRepeatableAndDoesNotOverwrite(t *testing.T) { + source, destination := t.TempDir(), t.TempDir() + var buf bytes.Buffer + if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 256, 256))); err != nil { + t.Fatal(err) + } + for _, file := range Avatars() { + if err := os.WriteFile(filepath.Join(source, file), buf.Bytes(), 0600); err != nil { + t.Fatal(err) + } + } + for i := 0; i < 2; i++ { + if err := CopyAvatars(source, destination); err != nil { + t.Fatal(err) + } + } + conflict := filepath.Join(destination, Avatars()[0]) + if err := os.WriteFile(conflict, []byte("existing user file"), 0600); err != nil { + t.Fatal(err) + } + if err := CopyAvatars(source, destination); err == nil { + t.Fatal("overwrote an unrelated file") + } + if got, _ := os.ReadFile(conflict); string(got) != "existing user file" { + t.Fatal("collision changed existing file") + } +} + +func TestBundledAvatarsArePresent(t *testing.T) { + if err := CopyAvatars(filepath.Join("..", "..", "..", "fixtures", "test-users", "avatars"), t.TempDir()); err != nil { + t.Fatal(err) + } +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/testusers/seed.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/testusers/seed.go new file mode 100644 index 0000000..12e0616 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/testusers/seed.go @@ -0,0 +1,107 @@ +package testusers + +import ( + "context" + "database/sql" + "fmt" +) + +type Result struct { + Batch string `json:"batch"` + Created int `json:"created"` + Skipped int `json:"skipped"` + Male int `json:"male"` + Female int `json:"female"` + IDs []int64 `json:"ids"` +} + +// Seed is atomic and repeatable: rerunning a complete batch is a no-op. +// Conflicting real users and incomplete/modified batches are never overwritten. +func Seed(ctx context.Context, db *sql.DB, publicBase, confirmDatabase string) (Result, error) { + result := Result{Batch: Batch, IDs: []int64{}} + profiles, err := Generate(publicBase) + if err != nil { + return result, err + } + conn, err := db.Conn(ctx) + if err != nil { + return result, err + } + defer conn.Close() + var database string + if err = conn.QueryRowContext(ctx, `SELECT DATABASE()`).Scan(&database); err != nil { + return result, err + } + if confirmDatabase == "" || database != confirmDatabase { + return result, fmt.Errorf("database confirmation does not match the connected database") + } + var locked int + if err = conn.QueryRowContext(ctx, `SELECT GET_LOCK(?,10)`, "xingyu:testusers:"+Batch).Scan(&locked); err != nil || locked != 1 { + return result, fmt.Errorf("could not acquire test-user batch lock") + } + defer func() { _, _ = conn.ExecContext(context.Background(), `DO RELEASE_LOCK(?)`, "xingyu:testusers:"+Batch) }() + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + return result, err + } + defer tx.Rollback() + var batchCount int + if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE test_batch=?`, Batch).Scan(&batchCount); err != nil { + return result, fmt.Errorf("test-user schema unavailable; apply migration 028 first: %w", err) + } + if batchCount != 0 && batchCount != Count { + return result, fmt.Errorf("batch has %d users, expected 0 or %d; refusing to modify partial data", batchCount, Count) + } + for _, p := range profiles { + var id int64 + var isTest bool + var existingBatch string + var deleted sql.NullTime + err = tx.QueryRowContext(ctx, `SELECT id,is_test,test_batch,deleted_at FROM users WHERE public_id=? FOR UPDATE`, p.PublicID).Scan(&id, &isTest, &existingBatch, &deleted) + if err == nil { + if batchCount != Count || !isTest || existingBatch != Batch || deleted.Valid { + return Result{}, fmt.Errorf("public ID %s conflicts with an existing or deleted user; no users changed", p.PublicID) + } + var gender int + if err = tx.QueryRowContext(ctx, `SELECT gender FROM user_profiles WHERE user_id=?`, id).Scan(&gender); err != nil || gender != p.Gender { + return Result{}, fmt.Errorf("existing test profile %s has been modified or is incomplete", p.PublicID) + } + result.Skipped++ + } else if err == sql.ErrNoRows && batchCount == 0 { + res, insertErr := tx.ExecContext(ctx, `INSERT INTO users (public_id,password_hash,is_test,test_batch,status) VALUES (?,'!TEST_PROFILE_NO_LOGIN',1,?,1)`, p.PublicID, Batch) + if insertErr != nil { + return Result{}, insertErr + } + id, err = res.LastInsertId() + if err != nil { + return Result{}, err + } + _, err = tx.ExecContext(ctx, `INSERT INTO user_profiles (user_id,nickname,avatar_url,cover_url,gender,birthday,height_cm,city_code,city_name,occupation,bio,profile_score) VALUES (?,?,?,'',?,?,?,?,?,?,?,80)`, id, p.Nickname, p.Avatar, p.Gender, p.Birthday, p.Height, p.CityCode, p.City, p.Occupation, p.Bio) + if err != nil { + return Result{}, err + } + // Do not invent GPS, recent activity, identity verification or memberships. + // An empty phone and invalid password hash make these display-only fixtures. + _, err = tx.ExecContext(ctx, `INSERT INTO user_privacy_settings (user_id,distance_visible,online_visible,last_active_visible,allow_profile_visit_record) VALUES (?,0,0,0,0)`, id) + if err != nil { + return Result{}, err + } + result.Created++ + } else { + if err == sql.ErrNoRows { + err = fmt.Errorf("batch identity mismatch at %s", p.PublicID) + } + return Result{}, err + } + if p.Gender == 1 { + result.Male++ + } else { + result.Female++ + } + result.IDs = append(result.IDs, id) + } + if err = tx.Commit(); err != nil { + return Result{}, err + } + return result, nil +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/internal/testusers/seed_integration_test.go b/im/.deploy/avatars-20260831-175508/source/backend/internal/testusers/seed_integration_test.go new file mode 100644 index 0000000..d997b06 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/internal/testusers/seed_integration_test.go @@ -0,0 +1,132 @@ +package testusers + +import ( + "context" + "database/sql" + "fmt" + "net" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/go-sql-driver/mysql" +) + +// This test never connects to the application database. It creates its own +// uniquely named schema on an explicitly supplied loopback MySQL connection. +func isolatedMySQL(t *testing.T) *sql.DB { + t.Helper() + dsn := os.Getenv("IM_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("set IM_TEST_MYSQL_DSN to enable isolated local MySQL integration tests") + } + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + t.Fatal("invalid test MySQL DSN") + } + host, _, err := net.SplitHostPort(cfg.Addr) + if err != nil || cfg.Net != "tcp" || (host != "127.0.0.1" && host != "localhost" && host != "::1") || cfg.DBName != "" { + t.Fatal("integration tests require a loopback TCP DSN without a database name") + } + cfg.ParseTime = true + admin, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { admin.Close() }) + database := fmt.Sprintf("im_fixture_test_%d", time.Now().UnixNano()) + if !regexp.MustCompile(`^im_fixture_test_[0-9]+$`).MatchString(database) { + t.Fatal("unsafe isolated database name") + } + if _, err = admin.Exec("CREATE DATABASE `" + database + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil { + t.Fatal(err) + } + // Only drop the exact schema successfully created by this test. + t.Cleanup(func() { + if _, err := admin.Exec("DROP DATABASE `" + database + "`"); err != nil { + t.Errorf("cleanup isolated schema %s: %v", database, err) + } + }) + cfg.DBName, cfg.MultiStatements = database, true + db, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + for _, name := range []string{"001_users.sql", "028_test_users.sql"} { + data, err := os.ReadFile(filepath.Join("..", "..", "migrations", name)) + if err != nil { + t.Fatal(err) + } + if _, err = db.Exec(string(data)); err != nil { + t.Fatal(err) + } + } + return db +} + +func TestMySQLSeedRepeatabilityAndIsolation(t *testing.T) { + db := isolatedMySQL(t) + var database string + if err := db.QueryRow(`SELECT DATABASE()`).Scan(&database); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO users (public_id,password_hash) VALUES ('REAL_FIXTURE','sentinel')`); err != nil { + t.Fatal(err) + } + ctx := context.Background() + if _, err := Seed(ctx, db, "https://example.com/uploads", "wrong_database"); err == nil { + t.Fatal("missing target-database protection") + } + first, err := Seed(ctx, db, "https://example.com/uploads", database) + if err != nil || first.Created != 100 || first.Male != 50 || first.Female != 50 || len(first.IDs) != 100 { + t.Fatalf("first seed: %+v, %v", first, err) + } + second, err := Seed(ctx, db, "https://example.com/uploads", database) + if err != nil || second.Created != 0 || second.Skipped != 100 || second.IDs[0] != first.IDs[0] { + t.Fatalf("repeat seed: %+v, %v", second, err) + } + for query, want := range map[string]int{ + `SELECT COUNT(*) FROM users WHERE is_test=0 AND test_batch='' AND password_hash='sentinel'`: 1, + `SELECT COUNT(*) FROM users WHERE is_test=1 AND phone_hash IS NULL AND phone_cipher IS NULL AND password_hash='!TEST_PROFILE_NO_LOGIN'`: 100, + `SELECT COUNT(*) FROM user_profiles WHERE last_active_at IS NULL AND is_vip=0 AND vip_level=0`: 100, + `SELECT COUNT(*) FROM user_profiles WHERE gender=1`: 50, + `SELECT COUNT(*) FROM user_profiles WHERE gender=2`: 50, + `SELECT COUNT(*) FROM user_sessions`: 0, + } { + var got int + if err := db.QueryRow(query).Scan(&got); err != nil || got != want { + t.Errorf("query %s: got %d want %d err %v", query, got, want, err) + } + } + if _, err := db.Exec(`UPDATE users SET test_batch='changed' WHERE public_id='TESTCN000100'`); err != nil { + t.Fatal(err) + } + if _, err := Seed(ctx, db, "https://example.com/uploads", database); err == nil || !strings.Contains(err.Error(), "expected 0 or 100") { + t.Fatalf("partial batch should not be silently repaired: %v", err) + } +} + +func TestMySQLSeedRollsBackOnRealUserCollision(t *testing.T) { + db := isolatedMySQL(t) + var database string + if err := db.QueryRow(`SELECT DATABASE()`).Scan(&database); err != nil { + t.Fatal(err) + } + // Force a late collision to prove earlier inserts in this batch roll back. + if _, err := db.Exec(`INSERT INTO users (public_id,password_hash) VALUES ('TESTCN000099','real-user-sentinel')`); err != nil { + t.Fatal(err) + } + if _, err := Seed(context.Background(), db, "https://example.com/uploads", database); err == nil { + t.Fatal("real-user collision must fail") + } + var total, tests, profiles int + _ = db.QueryRow(`SELECT COUNT(*),COALESCE(SUM(is_test),0) FROM users`).Scan(&total, &tests) + _ = db.QueryRow(`SELECT COUNT(*) FROM user_profiles`).Scan(&profiles) + if total != 1 || tests != 0 || profiles != 0 { + t.Fatalf("partial seed survived rollback: users=%d tests=%d profiles=%d", total, tests, profiles) + } +} diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/001_users.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/001_users.sql new file mode 100644 index 0000000..03659f5 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/001_users.sql @@ -0,0 +1,123 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + public_id VARCHAR(20) NOT NULL, + country_code VARCHAR(8) NOT NULL DEFAULT '+86', + phone_hash BINARY(32) NULL, + phone_cipher VARBINARY(255) NULL, + password_hash VARCHAR(255) NOT NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + risk_level TINYINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_users_public_id (public_id), + UNIQUE KEY uk_users_phone_hash (phone_hash), + KEY idx_users_status_created (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_profiles ( + user_id BIGINT UNSIGNED NOT NULL, + nickname VARCHAR(50) NOT NULL, + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + cover_url VARCHAR(500) NOT NULL DEFAULT '', + gender TINYINT UNSIGNED NOT NULL DEFAULT 0, + birthday DATE NULL, + height_cm SMALLINT UNSIGNED NULL, + city_code VARCHAR(20) NOT NULL DEFAULT '', + city_name VARCHAR(50) NOT NULL DEFAULT '', + occupation VARCHAR(100) NOT NULL DEFAULT '', + education TINYINT UNSIGNED NOT NULL DEFAULT 0, + relationship_status TINYINT UNSIGNED NOT NULL DEFAULT 0, + bio VARCHAR(500) NOT NULL DEFAULT '', + profile_score SMALLINT UNSIGNED NOT NULL DEFAULT 0, + is_vip TINYINT UNSIGNED NOT NULL DEFAULT 0, + vip_level TINYINT UNSIGNED NOT NULL DEFAULT 0, + last_active_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_profiles_city_active (city_code, last_active_at), + CONSTRAINT fk_profiles_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_privacy_settings ( + user_id BIGINT UNSIGNED NOT NULL, + nearby_visible TINYINT(1) NOT NULL DEFAULT 1, + distance_visible TINYINT(1) NOT NULL DEFAULT 1, + online_visible TINYINT(1) NOT NULL DEFAULT 1, + last_active_visible TINYINT(1) NOT NULL DEFAULT 1, + allow_stranger_message TINYINT(1) NOT NULL DEFAULT 1, + allow_profile_visit_record TINYINT(1) NOT NULL DEFAULT 1, + allow_search TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + CONSTRAINT fk_privacy_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_devices ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + device_id VARCHAR(100) NOT NULL, + platform VARCHAR(20) NOT NULL, + device_model VARCHAR(100) NOT NULL DEFAULT '', + os_version VARCHAR(50) NOT NULL DEFAULT '', + app_version VARCHAR(30) NOT NULL DEFAULT '', + push_provider VARCHAR(30) NOT NULL DEFAULT '', + push_token VARCHAR(255) NOT NULL DEFAULT '', + last_ip VARCHAR(45) NOT NULL DEFAULT '', + last_active_at DATETIME(3) NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_device_user_device (user_id, device_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_sessions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + device_id VARCHAR(100) NOT NULL, + refresh_token_hash BINARY(32) NOT NULL, + expires_at DATETIME(3) NOT NULL, + last_active_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + revoked_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sessions_user (user_id, revoked_at), + UNIQUE KEY uk_sessions_refresh (refresh_token_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tags ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + category VARCHAR(30) NOT NULL, + name VARCHAR(50) NOT NULL, + icon VARCHAR(100) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_tags_category_name (category, name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_tags ( + user_id BIGINT UNSIGNED NOT NULL, + tag_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, tag_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + username VARCHAR(50) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + real_name VARCHAR(50) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + last_login_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_admin_username (username) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/002_social.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/002_social.sql new file mode 100644 index 0000000..1944c57 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/002_social.sql @@ -0,0 +1,65 @@ +CREATE TABLE IF NOT EXISTS user_follows ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + target_user_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_follow_pair (user_id, target_user_id), + KEY idx_follow_target (target_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_likes ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + target_user_id BIGINT UNSIGNED NOT NULL, + source VARCHAR(30) NOT NULL DEFAULT 'discover', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_like_pair (user_id, target_user_id), + KEY idx_like_target (target_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_matches ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user1_id BIGINT UNSIGNED NOT NULL, + user2_id BIGINT UNSIGNED NOT NULL, + matched_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + PRIMARY KEY (id), + UNIQUE KEY uk_match_pair (user1_id, user2_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_blocks ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + blocked_user_id BIGINT UNSIGNED NOT NULL, + reason VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_block_pair (user_id, blocked_user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS profile_visits ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + viewer_user_id BIGINT UNSIGNED NOT NULL, + target_user_id BIGINT UNSIGNED NOT NULL, + source VARCHAR(30) NOT NULL DEFAULT 'profile', + visited_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_visit_target_time (target_user_id, visited_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_location_states ( + user_id BIGINT UNSIGNED NOT NULL, + city_code VARCHAR(20) NOT NULL, + location_cell VARCHAR(32) NOT NULL DEFAULT '', + latitude DECIMAL(10,7) NULL, + longitude DECIMAL(10,7) NULL, + last_location_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + source VARCHAR(20) NOT NULL DEFAULT 'gps', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_location_city_time (city_code, last_location_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/003_feed.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/003_feed.sql new file mode 100644 index 0000000..28a9be2 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/003_feed.sql @@ -0,0 +1,72 @@ +CREATE TABLE IF NOT EXISTS media_assets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + owner_user_id BIGINT UNSIGNED NOT NULL, + media_type VARCHAR(20) NOT NULL, + storage_provider VARCHAR(20) NOT NULL DEFAULT 'local', + bucket VARCHAR(100) NOT NULL DEFAULT '', + object_key VARCHAR(500) NOT NULL DEFAULT '', + public_url VARCHAR(500) NOT NULL DEFAULT '', + mime_type VARCHAR(100) NOT NULL DEFAULT '', + file_size BIGINT UNSIGNED NOT NULL DEFAULT 0, + width INT UNSIGNED NULL, + height INT UNSIGNED NULL, + duration_ms INT UNSIGNED NULL, + moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 1, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS posts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + content VARCHAR(2000) NOT NULL DEFAULT '', + visibility TINYINT UNSIGNED NOT NULL DEFAULT 1, + city_code VARCHAR(20) NOT NULL DEFAULT '', + location_text VARCHAR(100) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 1, + like_count INT UNSIGNED NOT NULL DEFAULT 0, + comment_count INT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id), + KEY idx_posts_status_created (status, created_at), + KEY idx_posts_user_created (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS post_media ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + post_id BIGINT UNSIGNED NOT NULL, + media_id BIGINT UNSIGNED NULL, + media_url VARCHAR(500) NOT NULL DEFAULT '', + media_type VARCHAR(20) NOT NULL DEFAULT 'image', + sort_order INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY idx_post_media_post (post_id, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS post_likes ( + post_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (post_id, user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS post_comments ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + post_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + parent_comment_id BIGINT UNSIGNED NULL, + reply_user_id BIGINT UNSIGNED NULL, + content VARCHAR(1000) NOT NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + deleted_at DATETIME(3) NULL, + PRIMARY KEY (id), + KEY idx_comments_post_created (post_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/004_im.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/004_im.sql new file mode 100644 index 0000000..63b090c --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/004_im.sql @@ -0,0 +1,68 @@ +CREATE TABLE IF NOT EXISTS im_conversations ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + conversation_type TINYINT UNSIGNED NOT NULL DEFAULT 1, + last_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + last_message_id BIGINT UNSIGNED NULL, + last_message_at DATETIME(3) NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_conversation_last (last_message_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_direct_conversations ( + conversation_id BIGINT UNSIGNED NOT NULL, + user1_id BIGINT UNSIGNED NOT NULL, + user2_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (conversation_id), + UNIQUE KEY uk_direct_pair (user1_id, user2_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_conversation_members ( + conversation_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + join_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + read_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + delivered_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + clear_seq BIGINT UNSIGNED NOT NULL DEFAULT 0, + pinned TINYINT(1) NOT NULL DEFAULT 0, + muted TINYINT(1) NOT NULL DEFAULT 0, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + joined_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (conversation_id, user_id), + KEY idx_member_user (user_id, updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_messages ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + conversation_id BIGINT UNSIGNED NOT NULL, + seq BIGINT UNSIGNED NOT NULL, + sender_id BIGINT UNSIGNED NOT NULL, + client_msg_id CHAR(26) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + message_type SMALLINT UNSIGNED NOT NULL, + body MEDIUMBLOB NOT NULL, + reply_to_message_id BIGINT UNSIGNED NULL, + moderation_status TINYINT UNSIGNED NOT NULL DEFAULT 1, + recalled_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_conv_seq (conversation_id, seq), + UNIQUE KEY uk_sender_client_msg (sender_id, client_msg_id), + KEY idx_conv_created (conversation_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_user_sync_events ( + user_id BIGINT UNSIGNED NOT NULL, + event_seq BIGINT UNSIGNED NOT NULL, + event_type SMALLINT UNSIGNED NOT NULL, + conversation_id BIGINT UNSIGNED NOT NULL, + message_seq BIGINT UNSIGNED NOT NULL, + event_data MEDIUMBLOB NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, event_seq), + KEY idx_sync_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/005_membership.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/005_membership.sql new file mode 100644 index 0000000..ed854a3 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/005_membership.sql @@ -0,0 +1,80 @@ +CREATE TABLE IF NOT EXISTS membership_plans ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + code VARCHAR(30) NOT NULL, + name VARCHAR(50) NOT NULL, + level TINYINT UNSIGNED NOT NULL, + duration_days INT UNSIGNED NOT NULL, + price_cent INT UNSIGNED NOT NULL, + original_price_cent INT UNSIGNED NOT NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_plan_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS benefit_definitions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + benefit_key VARCHAR(50) NOT NULL, + name VARCHAR(100) NOT NULL, + value_type VARCHAR(20) NOT NULL, + description VARCHAR(255) NOT NULL DEFAULT '', + PRIMARY KEY (id), + UNIQUE KEY uk_benefit_key (benefit_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS plan_benefits ( + plan_id BIGINT UNSIGNED NOT NULL, + benefit_id BIGINT UNSIGNED NOT NULL, + benefit_value VARCHAR(255) NOT NULL, + PRIMARY KEY (plan_id, benefit_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS subscriptions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + plan_id BIGINT UNSIGNED NOT NULL, + source VARCHAR(30) NOT NULL, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + started_at DATETIME(3) NOT NULL, + expires_at DATETIME(3) NOT NULL, + auto_renew TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sub_user_expire (user_id, expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_entitlements ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + benefit_key VARCHAR(50) NOT NULL, + benefit_value VARCHAR(255) NOT NULL, + source_type VARCHAR(30) NOT NULL, + source_id BIGINT UNSIGNED NOT NULL, + started_at DATETIME(3) NOT NULL, + expires_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_entitlement_user_key (user_id, benefit_key, expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS orders ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_no VARCHAR(40) NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + product_type VARCHAR(30) NOT NULL, + product_id BIGINT UNSIGNED NOT NULL, + amount_cent INT UNSIGNED NOT NULL, + currency CHAR(3) NOT NULL DEFAULT 'CNY', + status VARCHAR(20) NOT NULL DEFAULT 'CREATED', + channel VARCHAR(30) NOT NULL DEFAULT '', + paid_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_order_no (order_no), + KEY idx_order_user_created (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/006_moderation.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/006_moderation.sql new file mode 100644 index 0000000..887eec5 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/006_moderation.sql @@ -0,0 +1,72 @@ +CREATE TABLE IF NOT EXISTS reports ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + reporter_user_id BIGINT UNSIGNED NOT NULL, + target_type VARCHAR(30) NOT NULL, + target_id BIGINT UNSIGNED NOT NULL, + reason_code VARCHAR(50) NOT NULL, + description VARCHAR(1000) NOT NULL DEFAULT '', + evidence_json JSON NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + handled_by BIGINT UNSIGNED NULL, + handled_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_reports_status_created (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS moderation_tasks ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + target_type VARCHAR(30) NOT NULL, + target_id BIGINT UNSIGNED NOT NULL, + content_type VARCHAR(30) NOT NULL, + risk_score INT NOT NULL DEFAULT 0, + machine_result JSON NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + reviewer_id BIGINT UNSIGNED NULL, + review_result VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + reviewed_at DATETIME(3) NULL, + PRIMARY KEY (id), + KEY idx_moderation_status_created (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_risk_profiles ( + user_id BIGINT UNSIGNED NOT NULL, + risk_score INT NOT NULL DEFAULT 0, + risk_level TINYINT UNSIGNED NOT NULL DEFAULT 0, + message_score INT NOT NULL DEFAULT 0, + device_score INT NOT NULL DEFAULT 0, + report_score INT NOT NULL DEFAULT 0, + behavior_score INT NOT NULL DEFAULT 0, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_risk_level_score (risk_level, risk_score) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS risk_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + event_type VARCHAR(50) NOT NULL, + score_delta INT NOT NULL, + device_id VARCHAR(100) NOT NULL DEFAULT '', + ip VARCHAR(45) NOT NULL DEFAULT '', + metadata JSON NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_risk_event_user_created (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS notifications ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + type VARCHAR(30) NOT NULL, + title VARCHAR(100) NOT NULL, + content VARCHAR(1000) NOT NULL, + biz_type VARCHAR(30) NOT NULL DEFAULT '', + biz_id BIGINT UNSIGNED NULL, + read_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_notify_user_read_created (user_id, read_at, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/007_system.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/007_system.sql new file mode 100644 index 0000000..850e6f0 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/007_system.sql @@ -0,0 +1,70 @@ +CREATE TABLE IF NOT EXISTS system_configs ( + config_key VARCHAR(100) NOT NULL, + config_value TEXT NOT NULL, + value_type VARCHAR(20) NOT NULL DEFAULT 'string', + description VARCHAR(255) NOT NULL DEFAULT '', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (config_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS banners ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + title VARCHAR(100) NOT NULL, + image_url VARCHAR(500) NOT NULL, + link_url VARCHAR(500) NOT NULL DEFAULT '', + position VARCHAR(30) NOT NULL DEFAULT 'home', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + starts_at DATETIME(3) NULL, + ends_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS app_versions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + platform VARCHAR(20) NOT NULL, + version VARCHAR(30) NOT NULL, + build_number INT UNSIGNED NOT NULL, + force_update TINYINT(1) NOT NULL DEFAULT 0, + download_url VARCHAR(500) NOT NULL DEFAULT '', + release_notes VARCHAR(2000) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_version_platform_build (platform, build_number) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_audit_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + admin_user_id BIGINT UNSIGNED NOT NULL, + action VARCHAR(100) NOT NULL, + target_type VARCHAR(50) NOT NULL DEFAULT '', + target_id BIGINT UNSIGNED NULL, + request_data JSON NULL, + ip VARCHAR(45) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_audit_admin_created (admin_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('im.recall_seconds', '120', 'number', '消息撤回时间窗口'), + ('nearby.max_distance_km', '50', 'number', '附近的人最大距离'), + ('stranger.daily_limit', '10', 'number', '普通用户每日主动聊天人数') +ON DUPLICATE KEY UPDATE description = VALUES(description); + +INSERT INTO membership_plans (code, name, level, duration_days, price_cent, original_price_cent, status, sort_order) VALUES + ('VIP_1M', 'VIP 1个月', 1, 30, 2800, 4000, 1, 10), + ('VIP_3M', 'VIP 3个月', 1, 90, 6800, 9000, 1, 20), + ('SVIP_12M', 'SVIP 12个月', 2, 365, 22800, 36000, 1, 30) +ON DUPLICATE KEY UPDATE name = VALUES(name), price_cent = VALUES(price_cent), original_price_cent = VALUES(original_price_cent); + +INSERT INTO tags (category, name, icon, status, sort_order) VALUES + ('personality', '天秤座', '', 1, 10), + ('hobby', '摄影爱好者', '', 1, 20), + ('hobby', '旅行达人', '', 1, 30), + ('hobby', '电影', '', 1, 40), + ('hobby', '音乐', '', 1, 50) +ON DUPLICATE KEY UPDATE status = VALUES(status); + diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/008_integrations.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/008_integrations.sql new file mode 100644 index 0000000..e1d902e --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/008_integrations.sql @@ -0,0 +1,37 @@ +CREATE TABLE IF NOT EXISTS sms_verification_codes ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + phone_hash BINARY(32) NOT NULL, + scene VARCHAR(30) NOT NULL, + code_hash BINARY(32) NOT NULL, + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sms_phone_scene_created (phone_hash, scene, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('sms.enabled', 'true', 'boolean', '是否启用短信服务'), + ('sms.provider', 'debug', 'string', '短信提供商:debug 或 webhook'), + ('sms.sign_name', '星遇社交', 'string', '短信签名'), + ('sms.template_register', 'REGISTER', 'string', '注册验证码模板 ID'), + ('sms.template_login', 'LOGIN', 'string', '登录验证码模板 ID'), + ('sms.template_reset', 'RESET', 'string', '找回密码模板 ID'), + ('sms.webhook_url', '', 'string', '短信网关 Webhook 地址'), + ('sms.webhook_token', '', 'secret', '短信网关鉴权令牌'), + ('sms.debug_code', '123456', 'secret', '本地调试验证码'), + ('sms.expire_seconds', '300', 'number', '验证码有效期(秒)'), + ('payment.mode', 'sandbox', 'string', '支付模式:sandbox 或 live'), + ('payment.alipay.enabled', 'true', 'boolean', '是否启用支付宝'), + ('payment.alipay.app_id', '', 'string', '支付宝应用 APPID'), + ('payment.alipay.private_key', '', 'secret', '支付宝应用私钥'), + ('payment.alipay.public_key', '', 'secret', '支付宝公钥'), + ('payment.alipay.notify_url', '', 'string', '支付宝异步通知地址'), + ('payment.wechat.enabled', 'true', 'boolean', '是否启用微信支付'), + ('payment.wechat.app_id', '', 'string', '微信支付 AppID'), + ('payment.wechat.mch_id', '', 'string', '微信支付商户号'), + ('payment.wechat.api_v3_key', '', 'secret', '微信支付 APIv3 密钥'), + ('payment.wechat.private_key', '', 'secret', '微信支付商户私钥'), + ('payment.wechat.serial_no', '', 'string', '微信支付证书序列号'), + ('payment.wechat.notify_url', '', 'string', '微信支付回调地址') +ON DUPLICATE KEY UPDATE description = VALUES(description), value_type = VALUES(value_type); diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/009_repair_utf8_defaults.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/009_repair_utf8_defaults.sql new file mode 100644 index 0000000..d40b76f --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/009_repair_utf8_defaults.sql @@ -0,0 +1,6 @@ +-- PowerShell 5 may encode text piped to native executables as the active ANSI +-- code page. Repair the only user-visible non-ASCII integration default for +-- databases initialized by the earlier migration runner. +UPDATE system_configs +SET config_value = CONVERT(0xE6989FE98187E7A4BEE4BAA4 USING utf8mb4) +WHERE config_key = 'sms.sign_name' AND config_value = '????'; diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/010_admin_operations.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/010_admin_operations.sql new file mode 100644 index 0000000..4fea041 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/010_admin_operations.sql @@ -0,0 +1,64 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS user_verifications ( + user_id BIGINT UNSIGNED NOT NULL, + verification_type VARCHAR(30) NOT NULL DEFAULT 'real_name', + status VARCHAR(20) NOT NULL DEFAULT 'UNVERIFIED', + real_name VARCHAR(50) NOT NULL DEFAULT '', + document_mask VARCHAR(80) NOT NULL DEFAULT '', + remark VARCHAR(500) NOT NULL DEFAULT '', + reviewer_admin_id BIGINT UNSIGNED NULL, + submitted_at DATETIME(3) NULL, + reviewed_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_verification_status_updated (status, updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_sanctions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + sanction_type VARCHAR(30) NOT NULL, + reason VARCHAR(500) NOT NULL, + starts_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + expires_at DATETIME(3) NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + operator_admin_id BIGINT UNSIGNED NOT NULL, + revoked_by BIGINT UNSIGNED NULL, + revoked_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sanction_user_status_expire (user_id, status, expires_at), + KEY idx_sanction_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_security_controls ( + user_id BIGINT UNSIGNED NOT NULL, + token_version INT UNSIGNED NOT NULL DEFAULT 0, + force_logout_at DATETIME(3) NULL, + password_reset_at DATETIME(3) NULL, + last_operator_admin_id BIGINT UNSIGNED NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +SET @token_version_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_security_controls' AND COLUMN_NAME='token_version'); +SET @token_version_sql=IF(@token_version_exists=0,'ALTER TABLE user_security_controls ADD COLUMN token_version INT UNSIGNED NOT NULL DEFAULT 0 AFTER user_id','SELECT 1'); +PREPARE token_version_stmt FROM @token_version_sql; +EXECUTE token_version_stmt; +DEALLOCATE PREPARE token_version_stmt; + +INSERT INTO user_verifications (user_id, status) +SELECT u.id, 'UNVERIFIED' FROM users u +LEFT JOIN user_verifications v ON v.user_id=u.id +WHERE v.user_id IS NULL; + +INSERT INTO subscriptions (user_id, plan_id, source, status, started_at, expires_at) +SELECT p.user_id, mp.id, 'legacy_backfill', 1, NOW(3), DATE_ADD(NOW(3), INTERVAL mp.duration_days DAY) +FROM user_profiles p +JOIN membership_plans mp ON mp.level=p.vip_level + AND mp.duration_days=(SELECT MAX(mp2.duration_days) FROM membership_plans mp2 WHERE mp2.level=p.vip_level) +WHERE p.is_vip=1 AND p.vip_level>0 + AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.user_id=p.user_id AND s.status=1 AND s.expires_at>NOW(3)); diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/011_commerce_message_admin.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/011_commerce_message_admin.sql new file mode 100644 index 0000000..8d7fef8 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/011_commerce_message_admin.sql @@ -0,0 +1,25 @@ +SET NAMES utf8mb4; + +SET @plan_deleted_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='membership_plans' AND COLUMN_NAME='deleted_at'); +SET @plan_deleted_sql=IF(@plan_deleted_exists=0,'ALTER TABLE membership_plans ADD COLUMN deleted_at DATETIME(3) NULL AFTER updated_at','SELECT 1'); +PREPARE plan_deleted_stmt FROM @plan_deleted_sql; +EXECUTE plan_deleted_stmt; +DEALLOCATE PREPARE plan_deleted_stmt; + +SET @order_deleted_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='deleted_at'); +SET @order_deleted_sql=IF(@order_deleted_exists=0,'ALTER TABLE orders ADD COLUMN deleted_at DATETIME(3) NULL AFTER updated_at','SELECT 1'); +PREPARE order_deleted_stmt FROM @order_deleted_sql; +EXECUTE order_deleted_stmt; +DEALLOCATE PREPARE order_deleted_stmt; + +SET @plan_deleted_index_exists=(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='membership_plans' AND INDEX_NAME='idx_plan_deleted_sort'); +SET @plan_deleted_index_sql=IF(@plan_deleted_index_exists=0,'ALTER TABLE membership_plans ADD KEY idx_plan_deleted_sort (deleted_at,sort_order)','SELECT 1'); +PREPARE plan_deleted_index_stmt FROM @plan_deleted_index_sql; +EXECUTE plan_deleted_index_stmt; +DEALLOCATE PREPARE plan_deleted_index_stmt; + +SET @order_deleted_index_exists=(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND INDEX_NAME='idx_order_deleted_created'); +SET @order_deleted_index_sql=IF(@order_deleted_index_exists=0,'ALTER TABLE orders ADD KEY idx_order_deleted_created (deleted_at,created_at)','SELECT 1'); +PREPARE order_deleted_index_stmt FROM @order_deleted_index_sql; +EXECUTE order_deleted_index_stmt; +DEALLOCATE PREPARE order_deleted_index_stmt; diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/012_expand_client_message_id.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/012_expand_client_message_id.sql new file mode 100644 index 0000000..8010cc5 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/012_expand_client_message_id.sql @@ -0,0 +1,6 @@ +SET NAMES utf8mb4; + +-- Accept both the canonical 26-character client ID and UUID-style IDs from +-- older/cached clients while preserving sender-level idempotency. +ALTER TABLE im_messages + MODIFY COLUMN client_msg_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL; diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/013_security_hardening.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/013_security_hardening.sql new file mode 100644 index 0000000..543c563 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/013_security_hardening.sql @@ -0,0 +1,12 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS api_rate_limits ( + bucket_key BINARY(32) NOT NULL, + action_name VARCHAR(40) NOT NULL, + hits INT UNSIGNED NOT NULL DEFAULT 1, + expires_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (bucket_key), + KEY idx_rate_limit_expires (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/014_payment_gateway.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/014_payment_gateway.sql new file mode 100644 index 0000000..6a21487 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/014_payment_gateway.sql @@ -0,0 +1,61 @@ +SET NAMES utf8mb4; + +SET @provider_order_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='provider_order_no'); +SET @provider_order_sql=IF(@provider_order_exists=0,'ALTER TABLE orders ADD COLUMN provider_order_no VARCHAR(100) NOT NULL DEFAULT '''' AFTER channel','SELECT 1'); +PREPARE provider_order_stmt FROM @provider_order_sql; +EXECUTE provider_order_stmt; +DEALLOCATE PREPARE provider_order_stmt; + +SET @checkout_url_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='checkout_url'); +SET @checkout_url_sql=IF(@checkout_url_exists=0,'ALTER TABLE orders ADD COLUMN checkout_url VARCHAR(1000) NOT NULL DEFAULT '''' AFTER provider_order_no','SELECT 1'); +PREPARE checkout_url_stmt FROM @checkout_url_sql; +EXECUTE checkout_url_stmt; +DEALLOCATE PREPARE checkout_url_stmt; + +SET @payment_payload_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='payment_payload'); +SET @payment_payload_sql=IF(@payment_payload_exists=0,'ALTER TABLE orders ADD COLUMN payment_payload MEDIUMTEXT NULL AFTER checkout_url','SELECT 1'); +PREPARE payment_payload_stmt FROM @payment_payload_sql; +EXECUTE payment_payload_stmt; +DEALLOCATE PREPARE payment_payload_stmt; + +SET @paid_amount_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='paid_amount_cent'); +SET @paid_amount_sql=IF(@paid_amount_exists=0,'ALTER TABLE orders ADD COLUMN paid_amount_cent INT UNSIGNED NULL AFTER amount_cent','SELECT 1'); +PREPARE paid_amount_stmt FROM @paid_amount_sql; +EXECUTE paid_amount_stmt; +DEALLOCATE PREPARE paid_amount_stmt; + +SET @payment_notified_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND COLUMN_NAME='payment_notified_at'); +SET @payment_notified_sql=IF(@payment_notified_exists=0,'ALTER TABLE orders ADD COLUMN payment_notified_at DATETIME(3) NULL AFTER paid_at','SELECT 1'); +PREPARE payment_notified_stmt FROM @payment_notified_sql; +EXECUTE payment_notified_stmt; +DEALLOCATE PREPARE payment_notified_stmt; + +SET @provider_order_index_exists=(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='orders' AND INDEX_NAME='idx_order_provider_no'); +SET @provider_order_index_sql=IF(@provider_order_index_exists=0,'ALTER TABLE orders ADD KEY idx_order_provider_no (provider_order_no)','SELECT 1'); +PREPARE provider_order_index_stmt FROM @provider_order_index_sql; +EXECUTE provider_order_index_stmt; +DEALLOCATE PREPARE provider_order_index_stmt; + +CREATE TABLE IF NOT EXISTS payment_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + event_id VARCHAR(100) NOT NULL, + order_no VARCHAR(40) NOT NULL, + channel VARCHAR(30) NOT NULL, + provider_order_no VARCHAR(100) NOT NULL DEFAULT '', + event_status VARCHAR(30) NOT NULL, + amount_cent INT UNSIGNED NOT NULL DEFAULT 0, + raw_payload MEDIUMTEXT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_payment_event (event_id), + KEY idx_payment_event_order (order_no,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key,config_value,value_type,description) VALUES + ('payment.gateway.create_url','','string','统一支付网关创建支付地址'), + ('payment.gateway.token','','secret','统一支付网关 Bearer Token'), + ('payment.gateway.notify_secret','','secret','支付通知 HMAC-SHA256 密钥'), + ('payment.gateway.notify_url','','string','本系统支付通知公网 HTTPS 地址'), + ('payment.gateway.return_url','','string','支付完成后的客户端返回地址'), + ('payment.gateway.timeout_seconds','10','number','支付网关请求超时秒数') +ON DUPLICATE KEY UPDATE description=VALUES(description),value_type=VALUES(value_type); diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/015_payment_refunds.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/015_payment_refunds.sql new file mode 100644 index 0000000..d5bfc53 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/015_payment_refunds.sql @@ -0,0 +1,5 @@ +SET NAMES utf8mb4; + +INSERT INTO system_configs (config_key,config_value,value_type,description) VALUES + ('payment.gateway.refund_url','','string','统一支付网关退款申请地址') +ON DUPLICATE KEY UPDATE description=VALUES(description),value_type=VALUES(value_type); diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/016_admin_password_security.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/016_admin_password_security.sql new file mode 100644 index 0000000..0f0ffc7 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/016_admin_password_security.sql @@ -0,0 +1,6 @@ +SET NAMES utf8mb4; + +ALTER TABLE admin_users + ADD COLUMN token_version INT UNSIGNED NOT NULL DEFAULT 0 AFTER status, + ADD COLUMN password_changed_at DATETIME(3) NULL AFTER last_login_at; + diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/017_sms_cloud_providers.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/017_sms_cloud_providers.sql new file mode 100644 index 0000000..cb1a9d9 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/017_sms_cloud_providers.sql @@ -0,0 +1,39 @@ +SET NAMES utf8mb4; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('sms.aliyun.endpoint', 'https://dysmsapi.aliyuncs.com', 'string', '阿里云短信 API 地址'), + ('sms.aliyun.access_key_id', '', 'secret', '阿里云 AccessKey ID'), + ('sms.aliyun.access_key_secret', '', 'secret', '阿里云 AccessKey Secret'), + ('sms.aliyun.sign_name', '', 'string', '阿里云短信签名'), + ('sms.aliyun.template_register', '', 'string', '阿里云注册模板 Code'), + ('sms.aliyun.template_login', '', 'string', '阿里云登录模板 Code'), + ('sms.aliyun.template_reset', '', 'string', '阿里云重置密码模板 Code'), + ('sms.aliyun.template_params', '{"code":"{{code}}"}', 'string', '阿里云模板变量 JSON'), + + ('sms.tencent.endpoint', 'https://sms.tencentcloudapi.com', 'string', '腾讯云短信 API 地址'), + ('sms.tencent.secret_id', '', 'secret', '腾讯云 SecretId'), + ('sms.tencent.secret_key', '', 'secret', '腾讯云 SecretKey'), + ('sms.tencent.sdk_app_id', '', 'string', '腾讯云短信 SdkAppId'), + ('sms.tencent.region', 'ap-guangzhou', 'string', '腾讯云短信地域'), + ('sms.tencent.sign_name', '', 'string', '腾讯云短信签名'), + ('sms.tencent.template_register', '', 'string', '腾讯云注册模板 ID'), + ('sms.tencent.template_login', '', 'string', '腾讯云登录模板 ID'), + ('sms.tencent.template_reset', '', 'string', '腾讯云重置密码模板 ID'), + ('sms.tencent.template_params', '["{{code}}"]', 'string', '腾讯云模板参数 JSON'), + + ('sms.huawei.endpoint', '', 'string', '华为云短信 APP 接入地址'), + ('sms.huawei.app_key', '', 'secret', '华为云短信 Application Key'), + ('sms.huawei.app_secret', '', 'secret', '华为云短信 Application Secret'), + ('sms.huawei.sender', '', 'string', '华为云短信签名通道号'), + ('sms.huawei.signature', '', 'string', '华为云短信签名名称'), + ('sms.huawei.template_register', '', 'string', '华为云注册模板 ID'), + ('sms.huawei.template_login', '', 'string', '华为云登录模板 ID'), + ('sms.huawei.template_reset', '', 'string', '华为云重置密码模板 ID'), + ('sms.huawei.template_params', '["{{code}}"]', 'string', '华为云模板参数 JSON'), + ('sms.huawei.status_callback', '', 'string', '华为云短信状态回调地址') +ON DUPLICATE KEY UPDATE description = VALUES(description), value_type = VALUES(value_type); + +UPDATE system_configs +SET description = '短信提供商:aliyun、tencent、huawei、webhook 或 debug' +WHERE config_key = 'sms.provider'; + diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/018_object_storage_providers.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/018_object_storage_providers.sql new file mode 100644 index 0000000..9174ca5 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/018_object_storage_providers.sql @@ -0,0 +1,39 @@ +SET NAMES utf8mb4; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('storage.provider', 'local', 'string', '文件存储提供商:local、aliyun_oss、tencent_cos、qiniu、huawei_obs 或 huawei_flexus'), + ('storage.object_prefix', 'media', 'string', '云端对象键前缀'), + + ('storage.local.directory', './uploads', 'string', '本地文件存储目录'), + ('storage.local.public_base_url', '', 'string', '本地文件公开访问地址'), + + ('storage.aliyun_oss.endpoint', 'https://oss-cn-hangzhou.aliyuncs.com', 'string', '阿里云 OSS Endpoint'), + ('storage.aliyun_oss.region', 'cn-hangzhou', 'string', '阿里云 OSS Region'), + ('storage.aliyun_oss.bucket', '', 'string', '阿里云 OSS Bucket'), + ('storage.aliyun_oss.access_key_id', '', 'secret', '阿里云 OSS AccessKey ID'), + ('storage.aliyun_oss.access_key_secret', '', 'secret', '阿里云 OSS AccessKey Secret'), + ('storage.aliyun_oss.public_base_url', '', 'string', '阿里云 OSS 文件访问域名'), + + ('storage.tencent_cos.endpoint', '', 'string', '腾讯云 COS Bucket URL'), + ('storage.tencent_cos.bucket', '', 'string', '腾讯云 COS Bucket'), + ('storage.tencent_cos.secret_id', '', 'secret', '腾讯云 COS SecretId'), + ('storage.tencent_cos.secret_key', '', 'secret', '腾讯云 COS SecretKey'), + ('storage.tencent_cos.public_base_url', '', 'string', '腾讯云 COS 文件访问域名'), + + ('storage.qiniu.bucket', '', 'string', '七牛云 Kodo 空间名称'), + ('storage.qiniu.access_key', '', 'secret', '七牛云 AccessKey'), + ('storage.qiniu.secret_key', '', 'secret', '七牛云 SecretKey'), + ('storage.qiniu.public_base_url', '', 'string', '七牛云文件访问域名'), + + ('storage.huawei_obs.endpoint', 'https://obs.cn-north-4.myhuaweicloud.com', 'string', '华为云 OBS Endpoint'), + ('storage.huawei_obs.bucket', '', 'string', '华为云 OBS Bucket'), + ('storage.huawei_obs.access_key', '', 'secret', '华为云 OBS Access Key'), + ('storage.huawei_obs.secret_key', '', 'secret', '华为云 OBS Secret Key'), + ('storage.huawei_obs.public_base_url', '', 'string', '华为云 OBS 文件访问域名'), + + ('storage.huawei_flexus.endpoint', '', 'string', '华为云 Flexus 对象存储 Endpoint'), + ('storage.huawei_flexus.bucket', '', 'string', '华为云 Flexus 对象存储 Bucket'), + ('storage.huawei_flexus.access_key', '', 'secret', '华为云 Flexus 对象存储 Access Key'), + ('storage.huawei_flexus.secret_key', '', 'secret', '华为云 Flexus 对象存储 Secret Key'), + ('storage.huawei_flexus.public_base_url', '', 'string', '华为云 Flexus 对象存储文件访问域名') +ON DUPLICATE KEY UPDATE description = VALUES(description), value_type = VALUES(value_type); diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/019_daily_active_chat_limit.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/019_daily_active_chat_limit.sql new file mode 100644 index 0000000..24efbb6 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/019_daily_active_chat_limit.sql @@ -0,0 +1,34 @@ +SET @daily_chat_limit_exists=(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='membership_plans' AND COLUMN_NAME='daily_active_chat_limit'); +SET @daily_chat_limit_sql=IF(@daily_chat_limit_exists=0,'ALTER TABLE membership_plans ADD COLUMN daily_active_chat_limit INT UNSIGNED NOT NULL DEFAULT 20 AFTER duration_days','SELECT 1'); +PREPARE daily_chat_limit_stmt FROM @daily_chat_limit_sql; +EXECUTE daily_chat_limit_stmt; +DEALLOCATE PREPARE daily_chat_limit_stmt; + +UPDATE membership_plans +SET daily_active_chat_limit=CASE WHEN level>=2 THEN 100 ELSE 20 END +WHERE daily_active_chat_limit=20; + +INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES + ('membership.free_daily_active_chat_limit','5','integer','普通用户每日可主动聊天的不同用户数,0 表示不限制') +ON DUPLICATE KEY UPDATE description=VALUES(description); + +CREATE TABLE IF NOT EXISTS im_daily_active_chat_usage ( + user_id BIGINT UNSIGNED NOT NULL, + usage_date DATE NOT NULL, + used_count INT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id,usage_date), + KEY idx_daily_chat_usage_date (usage_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS im_daily_active_chat_targets ( + user_id BIGINT UNSIGNED NOT NULL, + target_user_id BIGINT UNSIGNED NOT NULL, + usage_date DATE NOT NULL, + conversation_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id,target_user_id,usage_date), + KEY idx_daily_chat_target_date (target_user_id,usage_date), + KEY idx_daily_chat_conversation (conversation_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/020_admin_oauth_login.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/020_admin_oauth_login.sql new file mode 100644 index 0000000..e93527f --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/020_admin_oauth_login.sql @@ -0,0 +1,87 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS admin_oauth_identities ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + provider VARCHAR(20) NOT NULL, + subject VARCHAR(191) NOT NULL, + admin_user_id BIGINT UNSIGNED NOT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + display_name VARCHAR(100) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + last_login_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_admin_oauth_provider_subject (provider, subject), + UNIQUE KEY uk_admin_oauth_user_provider (admin_user_id, provider), + CONSTRAINT fk_admin_oauth_identity_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_oauth_states ( + state_hash BINARY(32) NOT NULL, + provider VARCHAR(20) NOT NULL, + code_verifier VARCHAR(128) NOT NULL DEFAULT '', + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (state_hash), + KEY idx_admin_oauth_state_expiry (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_oauth_login_codes ( + code_hash BINARY(32) NOT NULL, + provider VARCHAR(20) NOT NULL, + subject VARCHAR(191) NOT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + display_name VARCHAR(100) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + admin_user_id BIGINT UNSIGNED NULL, + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (code_hash), + KEY idx_admin_oauth_code_expiry (expires_at), + KEY idx_admin_oauth_code_admin (admin_user_id), + CONSTRAINT fk_admin_oauth_code_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('oauth.admin.frontend_callback_url', 'http://localhost:5560/auth/social-callback', 'string', '第三方登录完成后跳转的管理端页面;生产环境必须使用 HTTPS'), + + ('oauth.wechat.enabled', 'false', 'boolean', '启用微信扫码登录'), + ('oauth.wechat.client_id', '', 'string', '微信开放平台网站应用 AppID'), + ('oauth.wechat.client_secret', '', 'secret', '微信开放平台网站应用 AppSecret'), + ('oauth.wechat.authorization_url', 'https://open.weixin.qq.com/connect/qrconnect', 'string', '微信登录授权地址'), + ('oauth.wechat.token_url', 'https://api.weixin.qq.com/sns/oauth2/access_token', 'string', '微信登录令牌地址'), + ('oauth.wechat.userinfo_url', 'https://api.weixin.qq.com/sns/userinfo', 'string', '微信用户信息地址'), + ('oauth.wechat.scope', 'snsapi_login', 'string', '微信网站应用登录授权范围'), + ('oauth.wechat.redirect_uri', 'http://127.0.0.1:8888/admin/v1/auth/oauth/callback', 'string', '微信开放平台登记的授权回调地址;生产环境必须使用 HTTPS'), + + ('oauth.qq.enabled', 'false', 'boolean', '启用 QQ 登录'), + ('oauth.qq.client_id', '', 'string', 'QQ 互联应用 AppID'), + ('oauth.qq.client_secret', '', 'secret', 'QQ 互联应用 AppKey'), + ('oauth.qq.authorization_url', 'https://graph.qq.com/oauth2.0/authorize', 'string', 'QQ 登录授权地址'), + ('oauth.qq.token_url', 'https://graph.qq.com/oauth2.0/token', 'string', 'QQ 登录令牌地址'), + ('oauth.qq.openid_url', 'https://graph.qq.com/oauth2.0/me', 'string', 'QQ OpenID 查询地址'), + ('oauth.qq.userinfo_url', 'https://graph.qq.com/user/get_user_info', 'string', 'QQ 用户信息地址'), + ('oauth.qq.scope', 'get_user_info', 'string', 'QQ 登录授权范围'), + ('oauth.qq.redirect_uri', 'http://127.0.0.1:8888/admin/v1/auth/oauth/callback', 'string', 'QQ 互联登记的授权回调地址;生产环境必须使用 HTTPS'), + + ('oauth.github.enabled', 'false', 'boolean', '启用 GitHub 登录'), + ('oauth.github.client_id', '', 'string', 'GitHub OAuth App Client ID'), + ('oauth.github.client_secret', '', 'secret', 'GitHub OAuth App Client Secret'), + ('oauth.github.authorization_url', 'https://github.com/login/oauth/authorize', 'string', 'GitHub OAuth 授权地址'), + ('oauth.github.token_url', 'https://github.com/login/oauth/access_token', 'string', 'GitHub OAuth 令牌地址'), + ('oauth.github.userinfo_url', 'https://api.github.com/user', 'string', 'GitHub 当前用户信息地址'), + ('oauth.github.scope', 'read:user user:email', 'string', 'GitHub 登录最小授权范围'), + ('oauth.github.redirect_uri', 'http://127.0.0.1:8888/admin/v1/auth/oauth/callback', 'string', 'GitHub OAuth App 登记的 callback URL;生产环境必须使用 HTTPS'), + + ('oauth.google.enabled', 'false', 'boolean', '启用 Google 登录'), + ('oauth.google.client_id', '', 'string', 'Google OAuth 2.0 Client ID'), + ('oauth.google.client_secret', '', 'secret', 'Google OAuth 2.0 Client Secret'), + ('oauth.google.authorization_url', 'https://accounts.google.com/o/oauth2/v2/auth', 'string', 'Google OAuth 授权地址'), + ('oauth.google.token_url', 'https://oauth2.googleapis.com/token', 'string', 'Google OAuth 令牌地址'), + ('oauth.google.userinfo_url', 'https://openidconnect.googleapis.com/v1/userinfo', 'string', 'Google OpenID Connect UserInfo 地址'), + ('oauth.google.scope', 'openid profile email', 'string', 'Google 登录授权范围'), + ('oauth.google.redirect_uri', 'http://127.0.0.1:8888/admin/v1/auth/oauth/callback', 'string', 'Google Cloud Console 登记的 redirect URI;生产环境必须使用 HTTPS') +ON DUPLICATE KEY UPDATE description = VALUES(description); diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/021_user_oauth_login.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/021_user_oauth_login.sql new file mode 100644 index 0000000..03e36c1 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/021_user_oauth_login.sql @@ -0,0 +1,54 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS user_oauth_identities ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + provider VARCHAR(20) NOT NULL, + subject VARCHAR(191) NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + display_name VARCHAR(100) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + last_login_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_user_oauth_provider_subject (provider, subject), + UNIQUE KEY uk_user_oauth_user_provider (user_id, provider), + CONSTRAINT fk_user_oauth_identity_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_oauth_states ( + state_hash BINARY(32) NOT NULL, + provider VARCHAR(20) NOT NULL, + code_verifier VARCHAR(128) NOT NULL DEFAULT '', + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (state_hash), + KEY idx_user_oauth_state_expiry (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_oauth_login_codes ( + code_hash BINARY(32) NOT NULL, + provider VARCHAR(20) NOT NULL, + subject VARCHAR(191) NOT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + display_name VARCHAR(100) NOT NULL DEFAULT '', + avatar_url VARCHAR(500) NOT NULL DEFAULT '', + user_id BIGINT UNSIGNED NULL, + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (code_hash), + KEY idx_user_oauth_code_expiry (expires_at), + KEY idx_user_oauth_code_user (user_id), + CONSTRAINT fk_user_oauth_code_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('oauth.user.frontend_callback_url', 'http://localhost:5174/#/pages/auth/oauth-callback', 'string', '第三方登录完成后跳转的 uni-app H5 页面;生产环境必须使用 HTTPS'), + ('oauth.user.wechat.enabled', 'false', 'boolean', '在客户端启用微信登录'), + ('oauth.user.qq.enabled', 'false', 'boolean', '在客户端启用 QQ 登录'), + ('oauth.user.github.enabled', 'false', 'boolean', '在客户端启用 GitHub 登录'), + ('oauth.user.google.enabled', 'false', 'boolean', '在客户端启用 Google 登录') +ON DUPLICATE KEY UPDATE description = VALUES(description); diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/022_admin_sessions.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/022_admin_sessions.sql new file mode 100644 index 0000000..0696e28 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/022_admin_sessions.sql @@ -0,0 +1,17 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS admin_sessions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + admin_user_id BIGINT UNSIGNED NOT NULL, + refresh_token_hash BINARY(32) NOT NULL, + user_agent_hash BINARY(32) NOT NULL, + ip VARCHAR(45) NOT NULL DEFAULT '', + expires_at DATETIME(3) NOT NULL, + last_active_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + revoked_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_admin_sessions_refresh (refresh_token_hash), + KEY idx_admin_sessions_user (admin_user_id, revoked_at, expires_at), + CONSTRAINT fk_admin_sessions_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/023_admin_rbac.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/023_admin_rbac.sql new file mode 100644 index 0000000..47da153 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/023_admin_rbac.sql @@ -0,0 +1,77 @@ +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS admin_roles ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + role_code VARCHAR(50) NOT NULL, + role_name VARCHAR(80) NOT NULL, + description VARCHAR(255) NOT NULL DEFAULT '', + built_in TINYINT(1) NOT NULL DEFAULT 0, + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_admin_roles_code (role_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_permissions ( + permission_code VARCHAR(80) NOT NULL, + permission_name VARCHAR(100) NOT NULL, + permission_group VARCHAR(50) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + PRIMARY KEY (permission_code), + KEY idx_admin_permissions_group (permission_group, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_role_permissions ( + role_id BIGINT UNSIGNED NOT NULL, + permission_code VARCHAR(80) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (role_id, permission_code), + CONSTRAINT fk_admin_role_permissions_role FOREIGN KEY (role_id) REFERENCES admin_roles(id) ON DELETE CASCADE, + CONSTRAINT fk_admin_role_permissions_permission FOREIGN KEY (permission_code) REFERENCES admin_permissions(permission_code) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS admin_user_roles ( + admin_user_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (admin_user_id, role_id), + KEY idx_admin_user_roles_role (role_id), + CONSTRAINT fk_admin_user_roles_user FOREIGN KEY (admin_user_id) REFERENCES admin_users(id) ON DELETE CASCADE, + CONSTRAINT fk_admin_user_roles_role FOREIGN KEY (role_id) REFERENCES admin_roles(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO admin_permissions (permission_code, permission_name, permission_group, sort_order) VALUES + ('dashboard:view', '查看运营概览', 'dashboard', 10), + ('users:view', '查看用户', 'users', 20), + ('users:manage', '编辑用户资料和会员', 'users', 21), + ('users:security', '重置密码和强制下线', 'users', 22), + ('verification:manage', '审核用户认证', 'users', 23), + ('violations:manage', '处理违规和账号处罚', 'users', 24), + ('content:view', '查看动态内容', 'content', 30), + ('content:manage', '删除和处置动态', 'content', 31), + ('messages:view', '查看聊天消息记录', 'content', 32), + ('reports:handle', '处理举报', 'risk', 40), + ('risk:view', '查看风险信息', 'risk', 41), + ('membership:manage', '管理会员套餐', 'commerce', 50), + ('orders:view', '查看订单', 'commerce', 51), + ('orders:manage', '变更订单和退款', 'commerce', 52), + ('system:manage', '管理系统、集成和管理员', 'system', 60) +ON DUPLICATE KEY UPDATE + permission_name=VALUES(permission_name), + permission_group=VALUES(permission_group), + sort_order=VALUES(sort_order); + +INSERT INTO admin_roles (role_code, role_name, description, built_in, status) +VALUES ('super_admin', '超级管理员', '拥有平台全部权限;内置角色不可删除', 1, 1) +ON DUPLICATE KEY UPDATE role_name=VALUES(role_name), description=VALUES(description), built_in=1, status=1; + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_code) +SELECT r.id, p.permission_code +FROM admin_roles r CROSS JOIN admin_permissions p +WHERE r.role_code='super_admin'; + +-- Existing administrators keep their current capabilities after RBAC is enabled. +INSERT IGNORE INTO admin_user_roles (admin_user_id, role_id) +SELECT a.id, r.id +FROM admin_users a JOIN admin_roles r ON r.role_code='super_admin'; diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/024_user_verification_submission.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/024_user_verification_submission.sql new file mode 100644 index 0000000..08a9e44 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/024_user_verification_submission.sql @@ -0,0 +1,11 @@ +SET NAMES utf8mb4; + +ALTER TABLE user_verifications + ADD COLUMN document_hash BINARY(32) NULL AFTER document_mask, + ADD COLUMN evidence_json TEXT NULL AFTER document_hash, + ADD KEY idx_user_verifications_document (document_hash); + +UPDATE user_verifications SET evidence_json='[]' WHERE evidence_json IS NULL OR evidence_json=''; + +ALTER TABLE user_verifications + MODIFY COLUMN evidence_json TEXT NOT NULL; diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/025_client_productization.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/025_client_productization.sql new file mode 100644 index 0000000..183194f --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/025_client_productization.sql @@ -0,0 +1,140 @@ +SET NAMES utf8mb4; + +ALTER TABLE user_privacy_settings + ADD COLUMN invisible_visit TINYINT(1) NOT NULL DEFAULT 0 AFTER allow_profile_visit_record; + +ALTER TABLE membership_plans + ADD COLUMN daily_like_limit INT UNSIGNED NOT NULL DEFAULT 20 AFTER daily_active_chat_limit, + ADD COLUMN can_view_visitors TINYINT(1) NOT NULL DEFAULT 0 AFTER daily_like_limit, + ADD COLUMN can_invisible_visit TINYINT(1) NOT NULL DEFAULT 0 AFTER can_view_visitors, + ADD COLUMN recommendation_weight INT UNSIGNED NOT NULL DEFAULT 0 AFTER can_invisible_visit; + +UPDATE membership_plans +SET daily_like_limit=CASE WHEN level>=2 THEN 0 WHEN level=1 THEN 100 ELSE 20 END, + can_view_visitors=IF(level>=1,1,0), + can_invisible_visit=IF(level>=1,1,0), + recommendation_weight=CASE WHEN level>=2 THEN 30 WHEN level=1 THEN 15 ELSE 0 END; + +ALTER TABLE user_verifications + DROP PRIMARY KEY, + ADD PRIMARY KEY (user_id, verification_type); + +CREATE TABLE IF NOT EXISTS user_daily_like_usage ( + user_id BIGINT UNSIGNED NOT NULL, + usage_date DATE NOT NULL, + used_count INT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, usage_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_daily_like_targets ( + user_id BIGINT UNSIGNED NOT NULL, + target_type VARCHAR(20) NOT NULL, + target_id BIGINT UNSIGNED NOT NULL, + usage_date DATE NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id, target_type, target_id, usage_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_notification_settings ( + user_id BIGINT UNSIGNED NOT NULL, + im_enabled TINYINT(1) NOT NULL DEFAULT 1, + interaction_enabled TINYINT(1) NOT NULL DEFAULT 1, + system_enabled TINYINT(1) NOT NULL DEFAULT 1, + sound_enabled TINYINT(1) NOT NULL DEFAULT 1, + vibration_enabled TINYINT(1) NOT NULL DEFAULT 1, + quiet_start VARCHAR(5) NOT NULL DEFAULT '', + quiet_end VARCHAR(5) NOT NULL DEFAULT '', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_push_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + device_id VARCHAR(100) NOT NULL, + provider VARCHAR(30) NOT NULL, + push_token VARCHAR(500) NOT NULL, + platform VARCHAR(20) NOT NULL DEFAULT '', + app_version VARCHAR(30) NOT NULL DEFAULT '', + status TINYINT UNSIGNED NOT NULL DEFAULT 1, + last_active_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_push_device_provider (user_id, device_id, provider), + KEY idx_push_user_status (user_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_feedback ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + category VARCHAR(30) NOT NULL, + content VARCHAR(2000) NOT NULL, + contact VARCHAR(100) NOT NULL DEFAULT '', + evidence_json JSON NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + reply_content VARCHAR(2000) NOT NULL DEFAULT '', + handled_by BIGINT UNSIGNED NULL, + handled_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_feedback_user_created (user_id, created_at), + KEY idx_feedback_status_created (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_account_closures ( + user_id BIGINT UNSIGNED NOT NULL, + reason VARCHAR(500) NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + requested_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + execute_after DATETIME(3) NOT NULL, + cancelled_at DATETIME(3) NULL, + completed_at DATETIME(3) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (user_id), + KEY idx_closure_status_execute (status, execute_after) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS user_consents ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + agreement_type VARCHAR(30) NOT NULL, + agreement_version VARCHAR(30) NOT NULL, + accepted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + ip VARCHAR(45) NOT NULL DEFAULT '', + device_id VARCHAR(100) NOT NULL DEFAULT '', + PRIMARY KEY (id), + UNIQUE KEY uk_user_consent_version (user_id, agreement_type, agreement_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE orders + ADD COLUMN refund_reason VARCHAR(500) NOT NULL DEFAULT '' AFTER payment_notified_at, + ADD COLUMN refund_requested_at DATETIME(3) NULL AFTER refund_reason; + +INSERT INTO user_notification_settings(user_id) +SELECT id FROM users +ON DUPLICATE KEY UPDATE user_id=VALUES(user_id); + +INSERT INTO system_configs (config_key,config_value,value_type,description) VALUES + ('membership.free_daily_like_limit','20','number','普通用户每日点赞次数'), + ('account.cancellation_cooling_days','7','number','账号注销冷静期天数'), + ('legal.user_agreement_version','1.0','string','用户协议版本'), + ('legal.privacy_policy_version','1.0','string','隐私政策版本'), + ('legal.operator_name','','string','平台运营主体全称(上线前必填)'), + ('legal.contact','','string','隐私与客服联系方式(上线前必填)'), + ('legal.effective_date','','string','协议生效日期,格式 YYYY-MM-DD'), + ('legal.user_agreement_url','','string','完整用户协议 HTTPS 地址'), + ('legal.privacy_policy_url','','string','完整隐私政策 HTTPS 地址'), + ('app.maintenance.enabled','false','boolean','客户端维护模式'), + ('app.maintenance.message','系统维护中,请稍后再试','string','客户端维护提示'), + ('app.features.nearby','true','boolean','附近功能开关'), + ('app.features.feed','true','boolean','动态功能开关'), + ('app.features.membership','true','boolean','会员功能开关'), + ('app.features.im','true','boolean','即时通讯功能开关'), + ('app.min_version.android','1.0.0','string','Android 最低版本'), + ('app.min_version.ios','1.0.0','string','iOS 最低版本'), + ('app.min_version.h5','1.0.0','string','H5 最低版本') +ON DUPLICATE KEY UPDATE description=VALUES(description),value_type=VALUES(value_type); diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/026_report_operations.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/026_report_operations.sql new file mode 100644 index 0000000..2a01644 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/026_report_operations.sql @@ -0,0 +1,17 @@ +ALTER TABLE reports + ADD COLUMN action_type VARCHAR(30) NOT NULL DEFAULT '' AFTER status, + ADD COLUMN handle_remark VARCHAR(1000) NOT NULL DEFAULT '' AFTER action_type; + +CREATE INDEX idx_reports_target ON reports(target_type, target_id, created_at); + +ALTER TABLE im_messages + ADD COLUMN admin_removed_at DATETIME(3) NULL AFTER recalled_at, + ADD COLUMN admin_removed_by BIGINT UNSIGNED NULL AFTER admin_removed_at, + ADD COLUMN admin_remove_reason VARCHAR(500) NOT NULL DEFAULT '' AFTER admin_removed_by; + +INSERT INTO admin_permissions(permission_code,permission_name,permission_group,sort_order) +VALUES('messages:manage','下架及恢复违规聊天消息','content',33) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),permission_group=VALUES(permission_group),sort_order=VALUES(sort_order); + +INSERT IGNORE INTO admin_role_permissions(role_id,permission_code) +SELECT id,'messages:manage' FROM admin_roles WHERE role_code='super_admin'; diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/027_app_oauth_login.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/027_app_oauth_login.sql new file mode 100644 index 0000000..167ae2b --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/027_app_oauth_login.sql @@ -0,0 +1,31 @@ +SET NAMES utf8mb4; + +ALTER TABLE user_oauth_states + ADD COLUMN client_platform VARCHAR(10) NOT NULL DEFAULT 'h5', + ADD COLUMN app_proof_hash BINARY(32) NULL; + +ALTER TABLE user_oauth_login_codes + ADD COLUMN client_platform VARCHAR(10) NOT NULL DEFAULT 'h5', + ADD COLUMN app_proof_hash BINARY(32) NULL, + ADD COLUMN identity_scope VARCHAR(191) NOT NULL DEFAULT ''; + +-- OpenID is scoped to the provider's application. Keep existing H5 bindings, +-- and allow a user to bind the mobile application identity independently. +ALTER TABLE user_oauth_identities + ADD COLUMN identity_scope VARCHAR(191) NOT NULL DEFAULT '', + DROP INDEX uk_user_oauth_provider_subject, + DROP INDEX uk_user_oauth_user_provider, + ADD UNIQUE KEY uk_user_oauth_provider_subject (provider, identity_scope, subject), + ADD UNIQUE KEY uk_user_oauth_user_provider (user_id, provider, identity_scope); + +INSERT INTO system_configs (config_key, config_value, value_type, description) VALUES + ('oauth.app.frontend_callback_url', 'xingyuim://oauth/callback', 'string', 'App GitHub 授权结果地址,必须与打包的 URL Scheme 一致'), + ('oauth.app.wechat.enabled', 'false', 'boolean', '在 App 启用微信原生登录'), + ('oauth.app.qq.enabled', 'false', 'boolean', '在 App 启用 QQ 原生登录'), + ('oauth.app.github.enabled', 'false', 'boolean', '在 App 启用 GitHub 系统浏览器登录'), + ('oauth.app.google.enabled', 'false', 'boolean', '在 App 启用 Google 原生登录'), + ('oauth.app.wechat.client_id', '', 'string', '微信开放平台移动应用 AppID'), + ('oauth.app.wechat.client_secret', '', 'secret', '微信移动应用 AppSecret,仅服务端保存'), + ('oauth.app.qq.client_id', '', 'string', 'QQ 互联移动应用 AppID'), + ('oauth.app.google.client_ids', '', 'string', '允许的 Google Android/iOS OAuth Client ID,以英文逗号分隔') +ON DUPLICATE KEY UPDATE description = VALUES(description); diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/028_test_users.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/028_test_users.sql new file mode 100644 index 0000000..e9e6321 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/028_test_users.sql @@ -0,0 +1,7 @@ +SET NAMES utf8mb4; + +-- Registration does not set these fields: genuine users remain non-test. +ALTER TABLE users + ADD COLUMN is_test TINYINT(1) NOT NULL DEFAULT 0, + ADD COLUMN test_batch VARCHAR(64) NOT NULL DEFAULT '', + ADD KEY idx_users_test_batch (is_test, test_batch, id); diff --git a/im/.deploy/avatars-20260831-175508/source/backend/migrations/029_admin_create_users.sql b/im/.deploy/avatars-20260831-175508/source/backend/migrations/029_admin_create_users.sql new file mode 100644 index 0000000..ab25065 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/backend/migrations/029_admin_create_users.sql @@ -0,0 +1,9 @@ +SET NAMES utf8mb4; + +INSERT INTO admin_permissions (permission_code, permission_name, permission_group, sort_order) +VALUES ('users:create', '新增客户端用户', 'users', 25) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), permission_group=VALUES(permission_group), sort_order=VALUES(sort_order); + +-- Grant to the built-in administrator role; other roles require an explicit grant. +INSERT IGNORE INTO admin_role_permissions (role_id, permission_code) +SELECT id, 'users:create' FROM admin_roles WHERE role_code='super_admin'; diff --git a/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/README.md b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/README.md new file mode 100644 index 0000000..8cd1c9b --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/README.md @@ -0,0 +1,110 @@ +# 100 个明确标识的测试用户 + +## 数据范围 + +- 固定批次:`cn-adults-20260831-v1`。 +- 用户数量:100,男 50、女 50;公开编号 `TESTCN000001` 至 `TESTCN000100`。 +- 所有资料都带独立字段 `users.is_test=1`、`users.test_batch`。正式注册用户默认 `is_test=0`,注册和资料修改接口不能将其改成测试标识或清除标识。 +- 昵称带“测试·”前缀,简介明确说明虚构身份;管理端有数据类型标签、类型/批次筛选和详情展示。客户端推荐、附近、搜索/关系列表、主页显示“测试用户”标签。 +- 仅创建用户、资料、隐私设置。无真实手机号、无可用密码、无登录会话、无虚假定位/在线时间、无认证/会员/订单/动态/聊天/粉丝记录。这批是展示用资料,不是自动聊天机器人,也不提供测试登录密码。 +- 测试用户不计入后台注册、新增、活跃及资料完善人数;测试人数单独显示。其他已有互动/订单指标的原口径未改动。 +- 头像为 imagegen 内置工具生成的虚构中国成年人形象,男 5 张、女 5 张;每张由 10 条同性别资料复用,共 10 张不同头像,并非 100 名真人或 100 张不同头像。文件与生成提示词见 `avatars/`、`avatar-provenance.json`。 + +`users.json` 是已经生成的 100 条本地预览数据,头像 URL 使用 `http://127.0.0.1:8888/uploads`。它不是已导入数据库的凭证。CLI 导入会按传入的公开域名重新生成 URL。 + +## 发布顺序 + +先确认要导入的环境。不要同时对本地和线上执行,也不要打开 `IM_SEED_DEMO`。 + +1. 备份目标库,按现有迁移机制应用 `backend/migrations/028_test_users.sql`(保留迁移校验记录)。它只新增测试字段/索引,原用户默认正式用户。 +2. 发布包含本次字段的后端,再发布管理端和 H5;原生 App 需重新构建。不要让新后端连接未迁移的库,否则列表会因字段缺失报错。 +3. 确认后台文件存储设置的 `storage.local.directory` 实际值;未设置时使用 `IM_MEDIA_DIR`。头像必须拷贝到此目录,不能仅拷贝到一个任意的 uploads 目录。 +4. 先运行预览,检查数量/性别/域名;再显式执行导入命令。 +5. 验证 SQL 统计、管理端筛选和客户端标识;抽查每张头像地址都返回图片而不是 HTML/404。 + +头像采用项目已有的数字文件名规则,经 `/uploads/:name` 访问。`--public-base` 必须是本地媒体服务的真实公开 URL;即使普通用户上传切换到云存储,这批内置测试素材仍需配置可访问的本地媒体目录或事先将相同文件发布到指定公开 URL。 + +## 本地 PowerShell + +在 `backend` 目录执行,项目自带 Go 在 `..\.tools\go\bin\go.exe`。 + +```powershell +# 只输出预览 JSON,不连接或修改数据库。 +& '..\.tools\go\bin\go.exe' run ./cmd/seed-test-users --public-base http://127.0.0.1:8888/uploads + +# 先在本机环境变量设置 IM_DB_DSN;不要把密码提交到代码仓库。 +$env:IM_ENV = 'development' +& '..\.tools\go\bin\go.exe' run ./cmd/seed-test-users --apply --confirm-database im --public-base http://127.0.0.1:8888/uploads --avatars-dir ../fixtures/test-users/avatars --media-dir ./uploads +``` + +## 服务器 / 宝塔 + +先将 `fixtures/test-users/avatars` 和编译好的导入工具上传服务器。Go 项目运行环境的 `IM_DB_DSN` 必须是实际目标库;注意该项目曾使用 3307 端口,不要误连到面板默认 3306 的另一个数据库。 + +```sh +# 在 backend 目录构建 Linux 工具;也可在服务器原生 go build。 +GOOS=linux GOARCH=amd64 go build -o seed-test-users ./cmd/seed-test-users + +# 服务器上,加载已经存在且可信的后端环境文件,不回显内容。 +set -a +. /etc/xingyu-im-bt.env +set +a + +# 以下路径必须对应已上传的素材和实际本地存储配置。 +./seed-test-users --apply --allow-production-test-data \ + --confirm-database im \ + --public-base https://im.bchongw.com/uploads \ + --avatars-dir ./avatars \ + --media-dir /www/wwwroot/xingyu-data/uploads +``` + +如果目标库名字不是 `im`,请使用真实名称;程序会校验确认名称与当前连接的 `DATABASE()` 完全一致。生产环境导入还需要显式 `--allow-production-test-data`。启动普通后端不会自动生成这些测试账号。 + +## 幂等性与出错行为 + +整批数据在一个事务中写入,并持有批次互斥锁。成功应返回 `created=100, male=50, female=50`。再次执行应返回 `created=0, skipped=100`。 + +遇到现有正式用户编号冲突、批次不完整、测试用户已删除或性别被修改时,程序拒绝覆盖,事务回滚。请人工核查,不要清空用户表或删库重来。程序不会为了补足 100 人而覆盖现有数据。已复制而未引用的头像可能保留在媒体目录,重试会复用内容相同的文件;内容不同的同名文件不会被覆盖。 + +验证: + +```sql +SELECT u.test_batch, p.gender, COUNT(*) AS total +FROM users u JOIN user_profiles p ON p.user_id=u.id +WHERE u.is_test=1 AND u.test_batch='cn-adults-20260831-v1' AND u.deleted_at IS NULL +GROUP BY u.test_batch, p.gender; + +SELECT is_test, COUNT(*) FROM users WHERE deleted_at IS NULL GROUP BY is_test; +``` + +第一条应是 gender=1、gender=2 各 50。管理端“用户管理”选择“测试用户”,批次输入 `cn-adults-20260831-v1` 应显示 100 条;选择“正式注册用户”不应包含本批次。 + +## 测试 + +`go test ./...` 包含数据数量/性别/年龄/标签/URL、素材复制防覆盖和过滤参数测试。 + +设置 `IM_TEST_MYSQL_DSN` 为不带数据库名的本机 MySQL 连接后,`go test ./internal/testusers -v` 还会创建两个独立的 `im_fixture_test_<数字>` 临时库,验证真实 MySQL 的重复执行、原用户保留、冲突回滚。测试结束只删除自己创建的临时库,不连接应用数据库。测试不接受远程主机或带数据库名的 DSN。 + +## 已导入测试头像迁移至 COS + +2026-08-31 已将线上本批次的 10 张头像上传到 `gz-1349751149`,并把 100 条资料的头像链接更新为 COS 的持久 HTTPS URL;男女各 50 和测试标签不变。原图片和旧链接备份仍在服务器,详细结果见 `deploy/im.bchongw.com_部署记录.md`。 + +专用工具 `backend/cmd/migrate-test-avatars-cos` 只操作本批次,不改变系统存储配置,不删除本地图片、云端对象或其他用户。凭证从服务器环境及数据库加密配置读取,禁止通过命令行参数或日志传递密钥。它严格确认生产库 `127.0.0.1:3307/im` 和指定存储桶;默认只做读取预览。 + +在服务器使用已加载可信后端环境变量的 shell 中: + +```sh +# 只读核对,不上传或更新数据库。 +/www/server/xingyu-im/ops/migrate-test-avatars-cos \ + --confirm-database im --confirm-bucket gz-1349751149 \ + --media-dir /www/wwwroot/xingyu-data/uploads + +# 正式执行需额外添加 --apply,以及一个尚不存在的专用备份目录: +# --backup-dir /www/backup/xingyu-cos-avatars-YYYYMMDD-HHMMSS +``` + +每个 COS 文件必须匿名下载成功且内容与原 PNG 完全一致,才会在一个事务中替换数据库链接。已有同名对象内容不符、测试头像曾被编辑、部分资料缺失或上传验证失败时均拒绝覆盖。已迁移的同内容对象会复用,已相同的数据库链接不重复更新。 + +上传遵循现有对象权限。工具只允许对本次新上传、确需公开显示的测试头像设置对象级公有读作为回退;不会修改存储桶 ACL 或已有对象 ACL。本次迁移未触发该回退,权限改动数量为 0。相关接口行为见 [腾讯云 PUT Object](https://cloud.tencent.com/document/product/436/7749)。 + +离线测试:在 `backend` 运行 `go test ./cmd/migrate-test-avatars-cos -v`,测试使用内存 HTTP 服务,不访问腾讯云或生产数据库。 diff --git a/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatar-provenance.json b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatar-provenance.json new file mode 100644 index 0000000..f5334c5 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatar-provenance.json @@ -0,0 +1,68 @@ +{ + "generator": "built-in image_gen", + "date": "2026-08-31", + "uniquePortraits": 10, + "reuse": "每个性别5张头像,50条同性别测试资料循环使用;并非100名真人或100张不同头像。", + "assets": [ + { + "file": "90002811-1.png", + "gender": 1, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese man aged 27, short black hair, gentle smile, navy casual shirt, blurred green park background. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermarks, no logos, no collage." + }, + { + "file": "90002812-1.png", + "gender": 1, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese man aged 30, oval face, neatly parted short black hair, thin round glasses, light grey knit shirt, relaxed bookshop background. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermark, no logos, no collage." + }, + { + "file": "90002813-1.png", + "gender": 1, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese man aged 24, short textured black hair, broad friendly smile, teal T-shirt, softly blurred riverside walkway. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermark, no logos, no collage." + }, + { + "file": "90002814-1.png", + "gender": 1, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese man aged 33, short cropped black hair, light facial stubble, earth brown casual jacket, soft urban cafe background. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermark, no logos, no collage." + }, + { + "file": "90002815-1.png", + "gender": 1, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese man aged 28, longer swept black hair, clean shaven, white crewneck and blue overshirt, neutral blue-grey studio backdrop. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermark, no logos, no collage." + }, + { + "file": "90002821-1.png", + "gender": 2, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese woman aged 26, shoulder-length black hair, gentle smile, cream casual blouse, blurred green park background. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermarks, no logos, no collage." + }, + { + "file": "90002822-1.png", + "gender": 2, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese woman aged 29, long straight black hair, thin oval glasses, sage green blouse, softly blurred bookshop background. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermark, no logos, no collage." + }, + { + "file": "90002823-1.png", + "gender": 2, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese woman aged 24, short black bob with light bangs, cheerful smile, light blue denim shirt, softly blurred riverside walkway. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermark, no logos, no collage." + }, + { + "file": "90002824-1.png", + "gender": 2, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese woman aged 32, tied-back black hair, natural friendly expression, burgundy casual knit top, soft urban cafe background. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermark, no logos, no collage." + }, + { + "file": "90002825-1.png", + "gender": 2, + "description": "AI生成的虚构中国成年人,不是真人身份", + "prompt": "Use case: photorealistic-natural. Asset type: square test-user avatar for a Chinese social app. Primary request: create a single photorealistic head-and-shoulders portrait of a fictional Chinese woman aged 27, wavy shoulder-length dark hair, natural smile, pale blue blouse, neutral warm-grey studio backdrop. Natural skin texture, soft daylight, centered face fully visible with comfortable margin for circle cropping. Only one person, fictional AI-generated adult, not a celebrity or an existing person. No text, no watermark, no logos, no collage." + } + ] +} diff --git a/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002811-1.png b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002811-1.png new file mode 100644 index 0000000..3a33179 Binary files /dev/null and b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002811-1.png differ diff --git a/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002812-1.png b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002812-1.png new file mode 100644 index 0000000..c6aaea4 Binary files /dev/null and b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002812-1.png differ diff --git a/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002813-1.png b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002813-1.png new file mode 100644 index 0000000..de330e3 Binary files /dev/null and b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002813-1.png differ diff --git a/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002814-1.png b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002814-1.png new file mode 100644 index 0000000..414bf56 Binary files /dev/null and b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002814-1.png differ diff --git a/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002815-1.png b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002815-1.png new file mode 100644 index 0000000..d588e8c Binary files /dev/null and b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002815-1.png differ diff --git a/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002821-1.png b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002821-1.png new file mode 100644 index 0000000..895db59 Binary files /dev/null and b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002821-1.png differ diff --git a/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002822-1.png b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002822-1.png new file mode 100644 index 0000000..f100b05 Binary files /dev/null and b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002822-1.png differ diff --git a/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002823-1.png b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002823-1.png new file mode 100644 index 0000000..cb10945 Binary files /dev/null and b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002823-1.png differ diff --git a/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002824-1.png b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002824-1.png new file mode 100644 index 0000000..ebe689b Binary files /dev/null and b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002824-1.png differ diff --git a/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002825-1.png b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002825-1.png new file mode 100644 index 0000000..3ca9013 Binary files /dev/null and b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/avatars/90002825-1.png differ diff --git a/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/users.json b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/users.json new file mode 100644 index 0000000..593296d --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/fixtures/test-users/users.json @@ -0,0 +1,1602 @@ +[ + { + "publicId": "TESTCN000001", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈沐川", + "gender": 1, + "birthday": "1999-01-01", + "height": 172, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000002", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈知远", + "gender": 1, + "birthday": "1996-02-02", + "height": 173, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000003", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈星河", + "gender": 1, + "birthday": "2002-03-03", + "height": 174, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000004", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈景行", + "gender": 1, + "birthday": "1993-04-04", + "height": 175, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000005", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈予安", + "gender": 1, + "birthday": "1998-05-05", + "height": 176, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000006", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林沐川", + "gender": 1, + "birthday": "1999-06-06", + "height": 177, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000007", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林知远", + "gender": 1, + "birthday": "1996-01-07", + "height": 178, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000008", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林星河", + "gender": 1, + "birthday": "2002-02-08", + "height": 179, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000009", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林景行", + "gender": 1, + "birthday": "1993-03-09", + "height": 180, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000010", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林予安", + "gender": 1, + "birthday": "1998-04-10", + "height": 181, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000011", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周沐川", + "gender": 1, + "birthday": "1999-05-11", + "height": 182, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000012", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周知远", + "gender": 1, + "birthday": "1996-06-12", + "height": 183, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000013", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周星河", + "gender": 1, + "birthday": "2002-01-13", + "height": 172, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000014", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周景行", + "gender": 1, + "birthday": "1993-02-14", + "height": 173, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000015", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周予安", + "gender": 1, + "birthday": "1998-03-15", + "height": 174, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000016", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许沐川", + "gender": 1, + "birthday": "1999-04-16", + "height": 175, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000017", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许知远", + "gender": 1, + "birthday": "1996-05-17", + "height": 176, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000018", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许星河", + "gender": 1, + "birthday": "2002-06-18", + "height": 177, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000019", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许景行", + "gender": 1, + "birthday": "1993-01-19", + "height": 178, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000020", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许予安", + "gender": 1, + "birthday": "1998-02-20", + "height": 179, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000021", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏沐川", + "gender": 1, + "birthday": "1999-03-21", + "height": 180, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000022", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏知远", + "gender": 1, + "birthday": "1996-04-22", + "height": 181, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000023", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏星河", + "gender": 1, + "birthday": "2002-05-23", + "height": 182, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000024", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏景行", + "gender": 1, + "birthday": "1993-06-24", + "height": 183, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000025", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏予安", + "gender": 1, + "birthday": "1998-01-25", + "height": 172, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000026", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈沐川", + "gender": 1, + "birthday": "1999-02-26", + "height": 173, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000027", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈知远", + "gender": 1, + "birthday": "1996-03-27", + "height": 174, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000028", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈星河", + "gender": 1, + "birthday": "2002-04-01", + "height": 175, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000029", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈景行", + "gender": 1, + "birthday": "1993-05-02", + "height": 176, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000030", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈予安", + "gender": 1, + "birthday": "1998-06-03", + "height": 177, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000031", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆沐川", + "gender": 1, + "birthday": "1999-01-04", + "height": 178, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000032", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆知远", + "gender": 1, + "birthday": "1996-02-05", + "height": 179, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000033", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆星河", + "gender": 1, + "birthday": "2002-03-06", + "height": 180, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000034", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆景行", + "gender": 1, + "birthday": "1993-04-07", + "height": 181, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000035", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆予安", + "gender": 1, + "birthday": "1998-05-08", + "height": 182, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000036", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾沐川", + "gender": 1, + "birthday": "1999-06-09", + "height": 183, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000037", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾知远", + "gender": 1, + "birthday": "1996-01-10", + "height": 172, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000038", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾星河", + "gender": 1, + "birthday": "2002-02-11", + "height": 173, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000039", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾景行", + "gender": 1, + "birthday": "1993-03-12", + "height": 174, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000040", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾予安", + "gender": 1, + "birthday": "1998-04-13", + "height": 175, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000041", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方沐川", + "gender": 1, + "birthday": "1999-05-14", + "height": 176, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000042", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方知远", + "gender": 1, + "birthday": "1996-06-15", + "height": 177, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000043", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方星河", + "gender": 1, + "birthday": "2002-01-16", + "height": 178, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000044", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方景行", + "gender": 1, + "birthday": "1993-02-17", + "height": 179, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000045", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方予安", + "gender": 1, + "birthday": "1998-03-18", + "height": 180, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000046", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季沐川", + "gender": 1, + "birthday": "1999-04-19", + "height": 181, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002811-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002811-1.png" + }, + { + "publicId": "TESTCN000047", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季知远", + "gender": 1, + "birthday": "1996-05-20", + "height": 182, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002812-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002812-1.png" + }, + { + "publicId": "TESTCN000048", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季星河", + "gender": 1, + "birthday": "2002-06-21", + "height": 183, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002813-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002813-1.png" + }, + { + "publicId": "TESTCN000049", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季景行", + "gender": 1, + "birthday": "1993-01-22", + "height": 172, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002814-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002814-1.png" + }, + { + "publicId": "TESTCN000050", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季予安", + "gender": 1, + "birthday": "1998-02-23", + "height": 173, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002815-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002815-1.png" + }, + { + "publicId": "TESTCN000051", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈晚晴", + "gender": 2, + "birthday": "2000-01-01", + "height": 160, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000052", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈知夏", + "gender": 2, + "birthday": "1997-02-02", + "height": 161, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000053", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈语桐", + "gender": 2, + "birthday": "2002-03-03", + "height": 162, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000054", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈清禾", + "gender": 2, + "birthday": "1994-04-04", + "height": 163, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000055", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陈若宁", + "gender": 2, + "birthday": "1999-05-05", + "height": 164, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + }, + { + "publicId": "TESTCN000056", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林晚晴", + "gender": 2, + "birthday": "2000-06-06", + "height": 165, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000057", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林知夏", + "gender": 2, + "birthday": "1997-01-07", + "height": 166, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000058", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林语桐", + "gender": 2, + "birthday": "2002-02-08", + "height": 167, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000059", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林清禾", + "gender": 2, + "birthday": "1994-03-09", + "height": 168, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000060", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·林若宁", + "gender": 2, + "birthday": "1999-04-10", + "height": 169, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + }, + { + "publicId": "TESTCN000061", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周晚晴", + "gender": 2, + "birthday": "2000-05-11", + "height": 170, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000062", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周知夏", + "gender": 2, + "birthday": "1997-06-12", + "height": 171, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000063", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周语桐", + "gender": 2, + "birthday": "2002-01-13", + "height": 160, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000064", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周清禾", + "gender": 2, + "birthday": "1994-02-14", + "height": 161, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000065", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·周若宁", + "gender": 2, + "birthday": "1999-03-15", + "height": 162, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + }, + { + "publicId": "TESTCN000066", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许晚晴", + "gender": 2, + "birthday": "2000-04-16", + "height": 163, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000067", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许知夏", + "gender": 2, + "birthday": "1997-05-17", + "height": 164, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000068", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许语桐", + "gender": 2, + "birthday": "2002-06-18", + "height": 165, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000069", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许清禾", + "gender": 2, + "birthday": "1994-01-19", + "height": 166, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000070", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·许若宁", + "gender": 2, + "birthday": "1999-02-20", + "height": 167, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + }, + { + "publicId": "TESTCN000071", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏晚晴", + "gender": 2, + "birthday": "2000-03-21", + "height": 168, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000072", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏知夏", + "gender": 2, + "birthday": "1997-04-22", + "height": 169, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000073", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏语桐", + "gender": 2, + "birthday": "2002-05-23", + "height": 170, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000074", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏清禾", + "gender": 2, + "birthday": "1994-06-24", + "height": 171, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000075", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·苏若宁", + "gender": 2, + "birthday": "1999-01-25", + "height": 160, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + }, + { + "publicId": "TESTCN000076", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈晚晴", + "gender": 2, + "birthday": "2000-02-26", + "height": 161, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000077", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈知夏", + "gender": 2, + "birthday": "1997-03-27", + "height": 162, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000078", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈语桐", + "gender": 2, + "birthday": "2002-04-01", + "height": 163, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000079", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈清禾", + "gender": 2, + "birthday": "1994-05-02", + "height": 164, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000080", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·沈若宁", + "gender": 2, + "birthday": "1999-06-03", + "height": 165, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + }, + { + "publicId": "TESTCN000081", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆晚晴", + "gender": 2, + "birthday": "2000-01-04", + "height": 166, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000082", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆知夏", + "gender": 2, + "birthday": "1997-02-05", + "height": 167, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000083", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆语桐", + "gender": 2, + "birthday": "2002-03-06", + "height": 168, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000084", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆清禾", + "gender": 2, + "birthday": "1994-04-07", + "height": 169, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000085", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·陆若宁", + "gender": 2, + "birthday": "1999-05-08", + "height": 170, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + }, + { + "publicId": "TESTCN000086", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾晚晴", + "gender": 2, + "birthday": "2000-06-09", + "height": 171, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000087", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾知夏", + "gender": 2, + "birthday": "1997-01-10", + "height": 160, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000088", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾语桐", + "gender": 2, + "birthday": "2002-02-11", + "height": 161, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000089", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾清禾", + "gender": 2, + "birthday": "1994-03-12", + "height": 162, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000090", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·顾若宁", + "gender": 2, + "birthday": "1999-04-13", + "height": 163, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + }, + { + "publicId": "TESTCN000091", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方晚晴", + "gender": 2, + "birthday": "2000-05-14", + "height": 164, + "cityCode": "310100", + "city": "上海", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000092", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方知夏", + "gender": 2, + "birthday": "1997-06-15", + "height": 165, + "cityCode": "440100", + "city": "广州", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000093", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方语桐", + "gender": 2, + "birthday": "2002-01-16", + "height": 166, + "cityCode": "440300", + "city": "深圳", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000094", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方清禾", + "gender": 2, + "birthday": "1994-02-17", + "height": 167, + "cityCode": "330100", + "city": "杭州", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000095", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·方若宁", + "gender": 2, + "birthday": "1999-03-18", + "height": 168, + "cityCode": "510100", + "city": "成都", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + }, + { + "publicId": "TESTCN000096", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季晚晴", + "gender": 2, + "birthday": "2000-04-19", + "height": 169, + "cityCode": "420100", + "city": "武汉", + "occupation": "设计师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:摄影与城市漫步。", + "avatarFile": "90002821-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002821-1.png" + }, + { + "publicId": "TESTCN000097", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季知夏", + "gender": 2, + "birthday": "1997-05-20", + "height": 170, + "cityCode": "320100", + "city": "南京", + "occupation": "工程师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:阅读与咖啡。", + "avatarFile": "90002822-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002822-1.png" + }, + { + "publicId": "TESTCN000098", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季语桐", + "gender": 2, + "birthday": "2002-06-21", + "height": 171, + "cityCode": "350200", + "city": "厦门", + "occupation": "教师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:跑步与音乐。", + "avatarFile": "90002823-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002823-1.png" + }, + { + "publicId": "TESTCN000099", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季清禾", + "gender": 2, + "birthday": "1994-01-22", + "height": 160, + "cityCode": "610100", + "city": "西安", + "occupation": "摄影师", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:旅行与美食。", + "avatarFile": "90002824-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002824-1.png" + }, + { + "publicId": "TESTCN000100", + "isTest": true, + "testBatch": "cn-adults-20260831-v1", + "label": "测试用户", + "nickname": "测试·季若宁", + "gender": 2, + "birthday": "1999-02-23", + "height": 161, + "cityCode": "370200", + "city": "青岛", + "occupation": "产品经理", + "bio": "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:电影与绘画。", + "avatarFile": "90002825-1.png", + "avatar": "http://127.0.0.1:8888/uploads/90002825-1.png" + } +] diff --git a/im/.deploy/avatars-20260831-175508/source/mobile/.env.development b/im/.deploy/avatars-20260831-175508/source/mobile/.env.development new file mode 100644 index 0000000..d59c8c6 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/mobile/.env.development @@ -0,0 +1,7 @@ +# HBuilderX Run to Device uses development mode. This is the deployed service, +# so test operations use its real data. Override in .env.development.local +# when a separate test backend is available. +VITE_API_ORIGIN=https://im.bchongw.com +VITE_WS_ORIGIN=wss://im.bchongw.com +VITE_APP_VERSION=1.0.0 +VITE_H5_BASE=/ diff --git a/im/.deploy/avatars-20260831-175508/source/mobile/.env.example b/im/.deploy/avatars-20260831-175508/source/mobile/.env.example new file mode 100644 index 0000000..9cddb70 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/mobile/.env.example @@ -0,0 +1,6 @@ +# Real devices cannot reach the development machine through 127.0.0.1. +# Replace this with the machine LAN address or the deployed API domain. +VITE_API_ORIGIN=https://im.bchongw.com +VITE_WS_ORIGIN=wss://im.bchongw.com +VITE_APP_VERSION=1.0.0 +VITE_H5_BASE=/ diff --git a/im/.deploy/avatars-20260831-175508/source/mobile/.env.production b/im/.deploy/avatars-20260831-175508/source/mobile/.env.production new file mode 100644 index 0000000..bcbdf08 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/mobile/.env.production @@ -0,0 +1,4 @@ +VITE_API_ORIGIN=https://im.bchongw.com +VITE_WS_ORIGIN=wss://im.bchongw.com +VITE_APP_VERSION=1.0.0 +VITE_H5_BASE=/app/ diff --git a/im/.deploy/avatars-20260831-175508/source/mobile/.gitignore b/im/.deploy/avatars-20260831-175508/source/mobile/.gitignore new file mode 100644 index 0000000..7ce6e50 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/mobile/.gitignore @@ -0,0 +1,21 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +*.local + +# Editor directories and files +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? \ No newline at end of file diff --git a/im/.deploy/avatars-20260831-175508/source/mobile/NATIVE_CONFIGURATION.md b/im/.deploy/avatars-20260831-175508/source/mobile/NATIVE_CONFIGURATION.md new file mode 100644 index 0000000..041c817 --- /dev/null +++ b/im/.deploy/avatars-20260831-175508/source/mobile/NATIVE_CONFIGURATION.md @@ -0,0 +1,116 @@ +# uni-app 原生发行配置 + +H5 构建不依赖厂商证书。`pnpm check:native` 检查 AppID 基础格式、模块、隐私声明及 HTTPS/WSS 地址;不会验证开发者账号、AppID 归属或签名证书,这些仍由 HBuilderX/DCloud 打包流程校验。 + +1. 在 DCloud 开发者中心创建应用,将实际分配的 AppID 写入 `src/manifest.json` 的 `appid`,不要自行拼造。预检兼容 7 位和 8 位十六进制后缀,保留当前项目已有的 AppID。 +2. 在 HBuilderX 的可视化 manifest 中配置应用包名、Android 签名证书和 iOS Bundle ID/证书。证书及密码不要提交到 Git。 +3. 按实际厂商填写微信/QQ登录、微信/支付宝支付和 UniPush 参数。客户端只展示后端 `/api/v1/auth/oauth/providers` 与 `/api/v1/payment/channels` 返回且已启用的渠道。 +4. 在 `mobile/.env.production` 配置 `VITE_API_ORIGIN` 和 `VITE_WS_ORIGIN`,预检和生产构建都会读取。终端/CI 显式注入的同名变量优先。域名必须具有有效 TLS 证书,反向代理须放行 `/ws` 升级连接。当前配置为 `https://im.bchongw.com` 和 `wss://im.bchongw.com`。 +5. Android 仅保留网络、定位、相机、录音和振动权限;iOS 的对应隐私用途说明已写入 manifest。首次使用能力时由页面按需申请,不在启动时集中索权。 +6. 运行下面的检查命令,再执行 `pnpm build:app-plus` 或使用 HBuilderX 云打包。 + +第三方 AppID、密钥、商户证书、短信签名和主体备案信息必须由真实主体申请,仓库不会提供可冒用的演示密钥。 + +## 在 HBuilderX 中导入及重新编译 + +本仓库的移动端是 Vue 3 CLI 工程。建议通过「文件 → 导入 → 从本地目录导入」选择 **`D:\web\age\im\mobile` 整个目录**,不要只选择 `mobile/src`。 + +- 整个工程导入会使用项目依赖,并保留根目录的 `vite.config.ts` 和 `.env` 配置。 +- 只导入 `src` 也是 HBuilderX 支持的另一种方式,但会改用 HBuilderX 内置编译器,工程根目录和环境配置位置也会变化,不适合直接沿用本项目的 CLI 配置。 +- 如果日志标签显示 `src - Xiaomi ...`,请检查当前选中的是否是单独导入的 `src` 工程。无需删除源码,重新导入并选择完整的 `mobile` 工程即可。 + +停止旧的运行任务,保存文件,再从「运行 → 运行到手机或模拟器」重新运行。生成安装包则使用「发行 → 原生 App 云打包」,根据实际发布平台填写包名和签名材料;签名/云打包成功才代表生成 APK/IPA。 + +## 真机调试与生产环境 + +### HBuilderX 提示找不到本地 Node.js + +本机 Node.js 路径是 `C:\nvm4w\nodejs\node.exe`,版本 `v22.22.0`。2026-08-31 已确认 HBuilderX 的 Node 检测先调用 `where node`;原 PATH 遗漏 `C:\Windows\System32`,导致 `where.exe` 找不到,从而误报 Node 未安装。 + +已仅向当前用户 PATH 补入 `C:\Windows\System32`,原有条目保留,系统级 PATH 未改动。通过 HBuilderX 自带 Node 重现其检测步骤,已能找到本机 Node.js。修改前的 PATH 备份在仓库 `.tools/hbuilderx-path-backup-20260831.json`(不提交到版本库)。 + +保存文件并完全退出 HBuilderX,再重新打开完整 `mobile` 工程,使新进程读取更新后的环境变量。可在新终端验证: + +```powershell +where.exe node +node --version +npm --version +``` + +不要为解决这一提示删除项目依赖、修改 HBuilderX 插件或重复安装 Node。 + +### 接口域名 + +`mobile/.env.production` 用于发行构建;HBuilderX「运行到手机/模拟器」使用 development 模式,不读取 `.env.production`。已在 `mobile/.env.development` 配置部署服务,调试和发行均可访问 `https://im.bchongw.com`,消息地址为 `wss://im.bchongw.com`。测试操作会使用该部署环境的真实数据。 + +如需独立测试服务器,可在 `mobile/.env.development.local` 覆盖地址。H5 本地开发也会读取 development 配置,请明确选择所需后端: + +```dotenv +VITE_API_ORIGIN=https://im.bchongw.com +VITE_WS_ORIGIN=wss://im.bchongw.com +``` + +连接开发电脑上的后端时需要手机可访问的 HTTPS 地址(也可使用可信的 HTTPS 开发隧道)。手机上的 `127.0.0.1` 指向手机自身,不是开发电脑。不要把后端密钥、数据库密码或商户私钥放进任何 `VITE_` 变量,它们会被编入客户端。 + +`dev:app-plus` 现在按 `--mode development` 预检,`build:app-plus` 按 `--mode production` 预检,并输出实际 API/WS 地址。预检拒绝回环地址。运行时会区别提示接口配置错误、DNS 失败、证书失败和超时,不会关闭 TLS 证书校验。 + +`VITE_H5_BASE=/app/` 仅用于 H5 网站,App 构建不会再沿用该网站子路径。环境文件读取位置固定为工程目录,不依赖 HBuilderX 启动时的工作目录。 + +## 检查与 App 资源编译 + +在 `mobile` 目录执行: + +```powershell +pnpm test:native-components +pnpm test:icons +pnpm test:native-config +pnpm type-check +pnpm check:native +pnpm build:app-plus +pnpm build:h5 +``` + +当前 CLI 编译器将 App 资源输出到 `dist/build/app`,H5 输出到 `dist/build/h5`。App 资源不是已签名 APK/IPA;本次仅验证了资源编译,没有提交云打包、签名或安装到用户手机。 + +## 已修复的 switch 编译错误 + +原隐私设置和消息通知页面使用了 ``,App Vue 编译时报 `v-model can only be used on ,