This commit is contained in:
Your Name
2026-08-27 14:04:28 +08:00
parent f7720831be
commit 334890171e
3016 changed files with 263403 additions and 27971 deletions
+19
View File
@@ -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()
}
+116
View File
@@ -0,0 +1,116 @@
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 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 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"`; 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"` }
@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 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 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 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 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
}
+73
View File
@@ -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
)
+187
View File
@@ -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=
+676
View File
@@ -0,0 +1,676 @@
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
}
token, _ := a.token(id, "admin", realName, 8*time.Hour)
_, _ = a.db.ExecContext(r.Context(), `UPDATE admin_users SET last_login_at=NOW(3) WHERE id=?`, id)
reply(w, map[string]any{"accessToken": token})
}
func (a *App) adminRefresh(w http.ResponseWriter, r *http.Request) {
reply(w, map[string]any{"data": "", "status": 0})
}
func (a *App) adminLogout(w http.ResponseWriter, r *http.Request) {
reply(w, map[string]bool{"success": true})
}
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.Commit(); err != nil {
fail(w, http.StatusInternalServerError, 50001, "修改密码失败")
return
}
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) adminCodes(w http.ResponseWriter, r *http.Request) {
reply(w, []string{"dashboard:view", "users:view", "users:manage", "users:security", "verification:manage", "violations:manage", "content:view", "content:manage", "messages:view", "reports:handle", "risk:view", "membership:manage", "orders:view", "orders:manage", "system:manage"})
}
func (a *App) adminInfo(w http.ResponseWriter, r *http.Request) {
who := current(r)
var username, realName, avatar string
_ = a.db.QueryRowContext(r.Context(), `SELECT username,real_name,avatar_url FROM admin_users WHERE id=?`, who.ID).Scan(&username, &realName, &avatar)
reply(w, map[string]any{"userId": who.ID, "username": username, "realName": realName, "avatar": avatar, "roles": []string{"super"}, "homePath": "/analytics"})
}
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 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{"users": count(`SELECT COUNT(*) FROM users WHERE deleted_at IS NULL`), "newUsersToday": count(`SELECT COUNT(*) FROM users WHERE DATE(created_at)=CURDATE()`), "activeUsers": count(`SELECT COUNT(*) FROM user_profiles WHERE 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`)}, {"name": "完善资料", "value": count(`SELECT COUNT(*) FROM user_profiles WHERE 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"))
where := ` WHERE u.deleted_at IS NULL`
args := []any{}
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)
}
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,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` + where + ` ORDER BY u.created_at 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
_ = rows.Scan(&id, &publicID, &phoneCipher, &status, &risk, &created, &nickname, &avatar, &gender, &city, &vip, &vipLevel, &active, &verificationStatus, &vipExpiresAt)
phone, _ := a.decryptPhone(phoneCipher)
items = append(items, map[string]any{"id": id, "publicId": publicID, "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})
}
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, _ := pathID(r)
_, err := a.db.ExecContext(r.Context(), `UPDATE posts SET status=0,deleted_at=NOW(3) WHERE id=?`, id)
if err != nil {
fail(w, 500, 50001, "删除失败")
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 status=?`
args = append(args, status)
}
var total int64
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM reports`+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,r.status,r.handled_by,r.handled_at,r.created_at FROM reports r JOIN user_profiles p ON p.user_id=r.reporter_user_id`+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, status string
var handler, handled any
var created time.Time
_ = rows.Scan(&id, &reporter, &nick, &targetType, &target, &reason, &description, &status, &handler, &handled, &created)
items = append(items, map[string]any{"id": id, "reporterId": reporter, "reporterName": nick, "targetType": targetType, "targetId": target, "reason": reason, "description": description, "status": status, "handledBy": handler, "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, _ := pathID(r)
var req struct {
Result string `json:"result"`
Remark string `json:"remark"`
SanctionType string `json:"sanctionType"`
DurationDays int `json:"durationDays"`
}
if decode(r, &req) != nil {
fail(w, 400, 20001, "invalid result")
return
}
status := "REJECTED"
if req.Result == "punished" {
status = "PUNISHED"
}
var targetType string
var targetID int64
if a.db.QueryRowContext(r.Context(), `SELECT target_type,target_id FROM reports WHERE id=? AND status='PENDING'`, id).Scan(&targetType, &targetID) != nil {
fail(w, 404, 30001, "待处理举报不存在")
return
}
if status == "PUNISHED" {
if targetType == "user" {
typ := strings.ToUpper(strings.TrimSpace(req.SanctionType))
if typ == "" {
typ = "WARNING"
}
if !map[string]bool{"WARNING": true, "FREEZE": true, "BAN": true, "MUTE": true, "CONTENT_LIMIT": true}[typ] {
fail(w, 400, 20001, "处罚类型无效")
return
}
var expiry *time.Time
if req.DurationDays > 0 {
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" {
_, _ = a.db.ExecContext(r.Context(), `UPDATE posts SET status=0,deleted_at=COALESCE(deleted_at,NOW(3)) WHERE id=?`, targetID)
}
}
_, err := a.db.ExecContext(r.Context(), `UPDATE reports SET status=?,handled_by=?,handled_at=NOW(3) WHERE id=?`, status, current(r).ID, id)
if err != nil {
fail(w, 500, 50001, "处理失败")
return
}
a.audit(r, "handle", "report", id, req)
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,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.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.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,price_cent,original_price_cent,status,sort_order)VALUES(?,?,?,?,?,?,?,?,?)`, req.Code, req.Name, req.Level, req.DurationDays, req.DailyActiveChatLimit, 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.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=?,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.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, &currency, &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
}
@@ -0,0 +1,219 @@
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 == "REFUNDING" || status == "REFUNDED" || status == "CLOSED"
}
func (a *App) syncEditedOrderEntitlement(r *http.Request, tx *sql.Tx, orderID, userID, oldPlanID, newPlanID int64, oldStatus, newStatus string) error {
needsRevoke := oldStatus == "PAID" && (newStatus != "PAID" || oldPlanID != newPlanID)
needsGrant := newStatus == "PAID" && (oldStatus != "PAID" || 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.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 string
var typ, moderation int
var body []byte
var recalledAt sql.NullTime
var createdAt time.Time
if rows.Scan(&id, &convID, &seq, &senderID, &publicID, &nickname, &avatar, &clientMsgID, &typ, &body, &moderation, &recalledAt, &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), "createdAt": createdAt})
}
reply(w, pageResult{Items: items, Total: total, Page: page, Size: size})
}
+747
View File
@@ -0,0 +1,747 @@
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
}
token, err := a.consumeAdminOAuthCodeAndIssueToken(r.Context(), strings.TrimSpace(req.Code), loginCode.AdminUserID.Int64)
if err != nil {
fail(w, http.StatusUnauthorized, 10001, err.Error())
return
}
reply(w, map[string]any{"accessToken": token, "requiresLink": false})
}
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))
token, _ := a.token(adminID, "admin", realName, 8*time.Hour)
reply(w, map[string]any{"accessToken": token, "requiresLink": false})
}
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) consumeAdminOAuthCodeAndIssueToken(ctx context.Context, code string, adminID 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 "", 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 "", errors.New("第三方登录处理失败")
}
affected, _ := result.RowsAffected()
if affected != 1 {
return "", 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)
token, err := a.token(adminID, "admin", realName, 8*time.Hour)
if err != nil {
return "", errors.New("创建登录令牌失败")
}
return token, nil
}
func (a *App) cleanupAdminOAuthRecords(ctx context.Context) {
_, _ = a.db.ExecContext(ctx, `DELETE FROM admin_oauth_states WHERE expires_at<DATE_SUB(NOW(3),INTERVAL 1 DAY) LIMIT 500`)
_, _ = a.db.ExecContext(ctx, `DELETE FROM admin_oauth_login_codes WHERE expires_at<DATE_SUB(NOW(3),INTERVAL 1 DAY) LIMIT 500`)
}
func (a *App) oauthHTTPClient() *http.Client {
return &http.Client{
Timeout: 8 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Token/UserInfo endpoints are fixed official endpoints and must not
// redirect credentials or bearer tokens to another host.
return http.ErrUseLastResponse
},
}
}
func (a *App) fetchAdminOAuthIdentity(ctx context.Context, provider adminOAuthProvider, code, verifier string) (adminOAuthIdentity, error) {
accessToken, tokenOpenID, err := a.exchangeAdminOAuthToken(ctx, provider, code, verifier)
if err != nil {
return adminOAuthIdentity{}, err
}
switch provider.Code {
case "github":
var payload struct {
ID int64 `json:"id"`
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
}
if err = a.oauthBearerJSON(ctx, provider.UserInfoURL, accessToken, &payload); err != nil || payload.ID <= 0 {
return adminOAuthIdentity{}, errors.New("GitHub 用户信息无效")
}
displayName := strings.TrimSpace(payload.Name)
if displayName == "" {
displayName = payload.Login
}
return adminOAuthIdentity{Subject: strconv.FormatInt(payload.ID, 10), Email: payload.Email, DisplayName: displayName, AvatarURL: payload.AvatarURL}, nil
case "google":
var payload struct {
Subject string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
Verified bool `json:"email_verified"`
}
if err = a.oauthBearerJSON(ctx, provider.UserInfoURL, accessToken, &payload); err != nil || strings.TrimSpace(payload.Subject) == "" {
return adminOAuthIdentity{}, errors.New("Google 用户信息无效")
}
if !payload.Verified {
payload.Email = ""
}
return adminOAuthIdentity{Subject: payload.Subject, Email: payload.Email, DisplayName: payload.Name, AvatarURL: payload.Picture}, nil
case "wechat":
values := url.Values{"access_token": {accessToken}, "openid": {tokenOpenID}, "lang": {"zh_CN"}}
var payload struct {
OpenID string `json:"openid"`
Nickname string `json:"nickname"`
AvatarURL string `json:"headimgurl"`
ErrorCode int `json:"errcode"`
}
if err = a.oauthGetJSON(ctx, provider.UserInfoURL, values, &payload); err != nil || payload.ErrorCode != 0 || payload.OpenID == "" {
return adminOAuthIdentity{}, errors.New("微信用户信息无效")
}
return adminOAuthIdentity{Subject: payload.OpenID, DisplayName: payload.Nickname, AvatarURL: payload.AvatarURL}, nil
case "qq":
openid, err := a.fetchQQOpenID(ctx, provider.OpenIDURL, accessToken)
if err != nil {
return adminOAuthIdentity{}, err
}
values := url.Values{"access_token": {accessToken}, "oauth_consumer_key": {provider.ClientID}, "openid": {openid}, "format": {"json"}}
var payload struct {
ReturnCode int `json:"ret"`
Message string `json:"msg"`
Nickname string `json:"nickname"`
AvatarURL string `json:"figureurl_qq_2"`
}
if err = a.oauthGetJSON(ctx, provider.UserInfoURL, values, &payload); err != nil || payload.ReturnCode != 0 {
return adminOAuthIdentity{}, errors.New("QQ 用户信息无效")
}
return adminOAuthIdentity{Subject: openid, DisplayName: payload.Nickname, AvatarURL: payload.AvatarURL}, nil
default:
return adminOAuthIdentity{}, errors.New("不支持的第三方登录渠道")
}
}
func (a *App) exchangeAdminOAuthToken(ctx context.Context, provider adminOAuthProvider, code, verifier string) (string, string, error) {
if provider.Code == "wechat" {
values := url.Values{"appid": {provider.ClientID}, "secret": {provider.ClientSecret}, "code": {code}, "grant_type": {"authorization_code"}}
var payload struct {
AccessToken string `json:"access_token"`
OpenID string `json:"openid"`
ErrorCode int `json:"errcode"`
}
if err := a.oauthGetJSON(ctx, provider.TokenURL, values, &payload); err != nil || payload.ErrorCode != 0 || payload.AccessToken == "" || payload.OpenID == "" {
return "", "", errors.New("微信令牌交换失败")
}
return payload.AccessToken, payload.OpenID, nil
}
values := url.Values{
"client_id": {provider.ClientID},
"client_secret": {provider.ClientSecret},
"code": {code},
"redirect_uri": {provider.RedirectURI},
"grant_type": {"authorization_code"},
}
if verifier != "" {
values.Set("code_verifier", verifier)
}
request, _ := http.NewRequestWithContext(ctx, http.MethodPost, provider.TokenURL, strings.NewReader(values.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
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("第三方令牌服务请求失败")
}
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
}
@@ -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())
}
}
+543
View File
@@ -0,0 +1,543 @@
package app
import (
"context"
"database/sql"
"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
err := a.db.QueryRowContext(r.Context(), `SELECT u.public_id,u.phone_cipher,u.status,u.risk_level,u.created_at,
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, &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": ""}
var verificationType, verificationStatus, realName, documentMask, verificationRemark string
var submittedAt, reviewedAt any
if a.db.QueryRowContext(r.Context(), `SELECT verification_type,status,real_name,document_mask,remark,submitted_at,reviewed_at FROM user_verifications WHERE user_id=?`, id).Scan(&verificationType, &verificationStatus, &realName, &documentMask, &verificationRemark, &submittedAt, &reviewedAt) == nil {
verification = map[string]any{"type": verificationType, "status": verificationStatus, "realName": realName, "documentMask": documentMask, "remark": verificationRemark, "submittedAt": submittedAt, "reviewedAt": reviewedAt}
}
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, "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, "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
}
_, err = a.db.ExecContext(r.Context(), `INSERT INTO user_verifications(user_id,verification_type,status,real_name,document_mask,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
}
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.db.ExecContext(ctx, `INSERT INTO notifications(user_id,type,title,content,biz_type) VALUES(?,'system',?,?,'sanction')`, userID, title, reason)
}
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" {
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")
}
+434
View File
@@ -0,0 +1,434 @@
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() {
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.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.MethodGet, Path: "/api/v1/users/search", Handler: auth(a.searchUsers)},
{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/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.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.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.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.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/reports", Handler: auth(a.createReport)},
{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) }
return []rest.Route{
{Method: http.MethodPost, Path: "/admin/v1/auth/logout", Handler: auth(a.adminLogout)},
{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: auth(a.dashboard)},
{Method: http.MethodGet, Path: "/admin/v1/users", Handler: auth(a.adminUsers)},
{Method: http.MethodGet, Path: "/admin/v1/users/:id", Handler: auth(a.adminUserDetail)},
{Method: http.MethodPut, Path: "/admin/v1/users/:id/profile", Handler: auth(a.adminUpdateUserProfile)},
{Method: http.MethodPut, Path: "/admin/v1/users/:id/verification", Handler: auth(a.adminUpdateVerification)},
{Method: http.MethodPut, Path: "/admin/v1/users/:id/membership", Handler: auth(a.adminUpdateMembership)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/password-reset", Handler: auth(a.adminResetUserPassword)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/force-logout", Handler: auth(a.adminForceLogout)},
{Method: http.MethodGet, Path: "/admin/v1/users/:id/sanctions", Handler: auth(a.adminUserSanctions)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/sanctions", Handler: auth(a.adminUserSanctions)},
{Method: http.MethodPost, Path: "/admin/v1/sanctions/:id/revoke", Handler: auth(a.adminRevokeSanction)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/freeze", Handler: auth(a.adminUserStatus)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/unfreeze", Handler: auth(a.adminUserStatus)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/ban", Handler: auth(a.adminUserStatus)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/unban", Handler: auth(a.adminUserStatus)},
{Method: http.MethodGet, Path: "/admin/v1/posts", Handler: auth(a.adminPosts)},
{Method: http.MethodGet, Path: "/admin/v1/posts/:id", Handler: auth(a.adminPostDetail)},
{Method: http.MethodDelete, Path: "/admin/v1/posts/:id", Handler: auth(a.adminDeletePost)},
{Method: http.MethodGet, Path: "/admin/v1/reports", Handler: auth(a.adminReports)},
{Method: http.MethodPost, Path: "/admin/v1/reports/:id/handle", Handler: auth(a.adminHandleReport)},
{Method: http.MethodGet, Path: "/admin/v1/risk/users", Handler: auth(a.adminRiskUsers)},
{Method: http.MethodGet, Path: "/admin/v1/risk/events", Handler: auth(a.adminRiskEvents)},
{Method: http.MethodGet, Path: "/admin/v1/membership/plans", Handler: auth(a.adminPlans)},
{Method: http.MethodPost, Path: "/admin/v1/membership/plans", Handler: auth(a.adminCreatePlan)},
{Method: http.MethodPatch, Path: "/admin/v1/membership/plans/:id", Handler: auth(a.adminUpdatePlan)},
{Method: http.MethodPut, Path: "/admin/v1/membership/plans/:id", Handler: auth(a.adminUpdatePlan)},
{Method: http.MethodDelete, Path: "/admin/v1/membership/plans/:id", Handler: auth(a.adminDeletePlan)},
{Method: http.MethodGet, Path: "/admin/v1/orders", Handler: auth(a.adminOrders)},
{Method: http.MethodPut, Path: "/admin/v1/orders/:id", Handler: auth(a.adminUpdateOrder)},
{Method: http.MethodDelete, Path: "/admin/v1/orders/:id", Handler: auth(a.adminDeleteOrder)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/pay", Handler: auth(a.adminMarkOrderPaid)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/close", Handler: auth(a.adminCloseOrder)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/refund", Handler: auth(a.adminRefund)},
{Method: http.MethodGet, Path: "/admin/v1/messages", Handler: auth(a.adminMessages)},
{Method: http.MethodGet, Path: "/admin/v1/system/configs", Handler: auth(a.adminConfigs)},
{Method: http.MethodPatch, Path: "/admin/v1/system/configs/:key", Handler: auth(a.adminUpdateConfig)},
{Method: http.MethodPut, Path: "/admin/v1/system/configs/:key", Handler: auth(a.adminUpdateConfig)},
{Method: http.MethodGet, Path: "/admin/v1/integrations/:group", Handler: auth(a.adminIntegration)},
{Method: http.MethodPut, Path: "/admin/v1/integrations/:group", Handler: auth(a.adminUpdateIntegration)},
{Method: http.MethodPost, Path: "/admin/v1/integrations/:group/test", Handler: auth(a.adminTestIntegration)},
{Method: http.MethodGet, Path: "/admin/v1/audit-logs", Handler: auth(a.adminAuditLogs)},
}
}
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
}
+263
View File
@@ -0,0 +1,263 @@
package app
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"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" {
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
}
func checkPassword(hash, password string) bool {
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)
}
+203
View File
@@ -0,0 +1,203 @@
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 + "%"
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.status=1 AND u.id<>? 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 50`, current(r).ID, pattern, pattern, current(r).ID, current(r).ID)
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": len(items), "keyword": keyword})
}
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) {
a.relationshipList(w, r, "visitors")
}
func (a *App) relationshipList(w http.ResponseWriter, r *http.Request, listType string) {
var rows *sql.Rows
var err error
switch listType {
case "following":
rows, err = a.db.QueryContext(r.Context(), `SELECT target_user_id FROM user_follows WHERE user_id=? ORDER BY created_at DESC LIMIT 100`, current(r).ID)
case "followers":
rows, err = a.db.QueryContext(r.Context(), `SELECT user_id FROM user_follows WHERE target_user_id=? ORDER BY created_at DESC LIMIT 100`, current(r).ID)
default:
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 100`, current(r).ID)
}
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": len(items), "type": listType})
}
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"`
AllowSearch bool `json:"allowSearch"`
}
func (a *App) getPrivacy(w http.ResponseWriter, r *http.Request) {
var values [7]int
err := a.db.QueryRowContext(r.Context(), `SELECT nearby_visible,distance_visible,online_visible,last_active_visible,allow_stranger_message,allow_profile_visit_record,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])
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, AllowSearch: values[6] == 1})
}
func (a *App) updatePrivacy(w http.ResponseWriter, r *http.Request) {
var req privacyView
if decode(r, &req) != nil {
fail(w, 400, 20001, "隐私设置格式错误")
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=?,allow_search=? WHERE user_id=?`, req.NearbyVisible, req.DistanceVisible, req.OnlineVisible, req.LastActiveVisible, req.AllowStrangerMessage, req.AllowProfileVisitRecord, 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
}
tx, _ := a.db.BeginTx(r.Context(), nil)
_, 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 {
_, _ = 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)
_, _ = 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)
err = tx.Commit()
}
if err != nil {
_ = tx.Rollback()
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.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
}
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 100`, userID, current(r).ID, current(r).ID)
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": len(items), "userId": userID})
}
+411
View File
@@ -0,0 +1,411 @@
package app
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"sync"
"time"
"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"`
CreatedAt time.Time `json:"createdAt"`
}
type wsClient struct {
userID int64
conn *websocket.Conn
mu sync.Mutex
}
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.mu.Lock()
_ = client.conn.WriteJSON(payload)
client.mu.Unlock()
}
}
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 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
}
tx, _ := a.db.BeginTx(r.Context(), nil)
result, err := tx.ExecContext(r.Context(), `INSERT INTO im_conversations(conversation_type)VALUES(1)`)
if err != nil {
_ = tx.Rollback()
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,COALESCE(CAST(msg.body AS CHAR CHARACTER SET utf8mb4),'')
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 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
_ = rows.Scan(&id, &lastSeq, &lastAt, &readSeq, &pinned, &muted, &otherID, &nick, &avatar, &vip, &active, &body)
preview := "开始聊天吧"
var content map[string]any
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": 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
}
rows, err := a.db.QueryContext(r.Context(), `SELECT id,conversation_id,seq,sender_id,client_msg_id,message_type,body,created_at FROM im_messages WHERE conversation_id=? AND recalled_at IS NULL ORDER BY seq DESC LIMIT 100`, id)
if err != nil {
fail(w, 500, 50001, "查询消息失败")
return
}
defer rows.Close()
items := []messageView{}
for rows.Next() {
var item messageView
var body []byte
_ = rows.Scan(&item.ID, &item.ConversationID, &item.Seq, &item.SenderID, &item.ClientMsgID, &item.Type, &body, &item.CreatedAt)
var content any
_ = json.Unmarshal(body, &content)
item.Content = content
items = append(items, item)
}
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)
}
reply(w, map[string]any{"items": items, "hasMore": false})
}
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
}
if req.ClientMsgID == "" {
req.ClientMsgID = fmt.Sprintf("%026d", time.Now().UnixNano())
}
if len(req.ClientMsgID) > 64 {
fail(w, 400, 20001, "客户端消息 ID 长度不能超过 64 个字符")
return
}
if req.Type == 0 {
req.Type = 1
}
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) persistMessage(r *http.Request, conversationID, senderID int64, clientMsgID string, messageType int, content any) (messageView, []int64, error) {
item := messageView{}
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("不是会话成员")
}
if err = a.reserveDailyActiveChat(r.Context(), tx, conversationID, senderID); err != nil {
return item, nil, err
}
body, err := json.Marshal(content)
if err != nil {
return item, nil, fmt.Errorf("消息内容错误")
}
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
}
_, _ = tx.ExecContext(r.Context(), `UPDATE im_conversation_members SET delivered_seq=GREATEST(delivered_seq,?),updated_at=NOW(3) WHERE conversation_id=?`, seq, conversationID)
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
_ = memberRows.Scan(&userID)
members = append(members, userID)
}
_ = memberRows.Close()
for _, userID := range members {
var next int64
_ = tx.QueryRowContext(r.Context(), `SELECT COALESCE(MAX(event_seq),0)+1 FROM im_user_sync_events WHERE user_id=?`, userID).Scan(&next)
_, _ = 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)
}
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
_ = a.db.QueryRowContext(r.Context(), `SELECT id,conversation_id,seq,sender_id,client_msg_id,message_type,body,created_at FROM im_messages WHERE id=?`, id).Scan(&item.ID, &item.ConversationID, &item.Seq, &item.SenderID, &item.ClientMsgID, &item.Type, &body, &item.CreatedAt)
_ = 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, _ := pathID(r)
var req struct {
Pinned *bool `json:"pinned"`
Muted *bool `json:"muted"`
ReadSeq int64 `json:"readSeq"`
}
if decode(r, &req) != nil {
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)
}
_, 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, req.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
}
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"))
}}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
client := &wsClient{userID: who.ID, conn: conn}
a.hub.add(client)
defer func() { a.hub.remove(client); _ = conn.Close() }()
_ = conn.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.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 {
_ = conn.WriteJSON(map[string]any{"command": "ERROR", "message": persistErr.Error()})
continue
}
a.hub.broadcast(members, map[string]any{"command": "MESSAGE_PUSH", "data": item})
case "READ":
_, _ = 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
}
+577
View File
@@ -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 用户 AKAES-GCM 加密保存"},
{Key: "storage.huawei_obs.secret_key", Label: "华为云 OBS Secret Key", Input: "secret", Required: true, Providers: []string{"huawei_obs"}, Description: "IAM 用户 SKAES-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_SecretAES-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
}
+187
View File
@@ -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)))
}
+282
View File
@@ -0,0 +1,282 @@
package app
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"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"`
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,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.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)
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, "level": 0, "name": "普通用户"})
return
}
reply(w, map[string]any{"active": true, "dailyActiveChat": quota, "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 string
var amountCent int
var paidAt sql.NullTime
if err = a.db.QueryRowContext(r.Context(), `SELECT order_no,amount_cent,status,channel,paid_at FROM orders WHERE id=? AND user_id=? AND deleted_at IS NULL`, id, current(r).ID).Scan(&orderNo, &amountCent, &status, &channel, &paidAt); err != nil {
fail(w, http.StatusNotFound, 30001, "订单不存在")
return
}
result := map[string]any{"id": id, "orderNo": orderNo, "amountCent": amountCent, "status": status, "channel": channel}
if paidAt.Valid {
result["paidAt"] = paidAt.Time
}
reply(w, result)
}
func (a *App) myOrders(w http.ResponseWriter, r *http.Request) {
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`, 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 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})
}
func (a *App) notifications(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT id,type,title,content,biz_type,biz_id,read_at,created_at FROM notifications 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 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})
}
reply(w, map[string]any{"items": items})
}
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
}
}
reply(w, map[string]any{"configs": configs, "features": map[string]bool{"nearby": true, "feed": true, "membership": true, "im": true}, "minVersion": "1.0.0"})
}
@@ -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<DATE_ADD(CURRENT_DATE(),INTERVAL 1 DAY)
)`, conversationID, targetUserID).Scan(&inboundToday); err != nil {
return err
}
if inboundToday == 1 {
return nil
}
if _, err = tx.ExecContext(ctx, `INSERT IGNORE INTO im_daily_active_chat_usage(user_id,usage_date,used_count) VALUES(?,CURRENT_DATE(),0)`, senderID); err != nil {
return err
}
var used int
if err = tx.QueryRowContext(ctx, `SELECT used_count FROM im_daily_active_chat_usage WHERE user_id=? AND usage_date=CURRENT_DATE() FOR UPDATE`, senderID).Scan(&used); err != nil {
return err
}
var alreadyCounted int
if err = tx.QueryRowContext(ctx, `SELECT EXISTS(
SELECT 1 FROM im_daily_active_chat_targets
WHERE user_id=? AND target_user_id=? AND usage_date=CURRENT_DATE()
)`, senderID, targetUserID).Scan(&alreadyCounted); err != nil {
return err
}
if alreadyCounted == 1 {
return nil
}
limit := a.resolveDailyActiveChatLimit(ctx, tx, senderID)
if limit > 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
}
@@ -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)
}
}
+347
View File
@@ -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, &notice) != 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 != "REFUNDING" {
fail(w, http.StatusBadRequest, 20001, "只有已支付或退款处理中的订单可发起退款")
return
}
if providerOrderNo == "" {
fail(w, http.StatusBadRequest, 20001, "订单缺少支付渠道流水号,不能自动退款")
return
}
if status == "PAID" {
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
}
+325
View File
@@ -0,0 +1,325 @@
package app
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/binary"
"fmt"
"net/http"
"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" {
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, _ := hashPassword(req.Password)
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, _ := result.LastInsertId()
_, 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
}
_, _ = tx.ExecContext(r.Context(), `INSERT INTO user_privacy_settings (user_id) VALUES (?)`, userID)
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"
}
accessToken, _ := a.token(id, "user", nickname, 30*time.Minute)
refresh := randomToken()
refreshHash := sha256.Sum256([]byte(refresh))
_, _ = a.db.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[:])
_, _ = a.db.ExecContext(r.Context(), `UPDATE user_profiles SET last_active_at=NOW(3) WHERE user_id=?`, id)
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
}
hash := sha256.Sum256([]byte(req.RefreshToken))
var id int64
var nickname string
err := a.db.QueryRowContext(r.Context(), `SELECT s.user_id,p.nickname 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`, hash[:]).Scan(&id, &nickname)
if err != nil {
fail(w, 401, 10001, "刷新令牌无效")
return
}
accessToken, _ := a.token(id, "user", nickname, 30*time.Minute)
reply(w, map[string]any{"accessToken": accessToken, "expiresIn": 1800})
}
func (a *App) logout(w http.ResponseWriter, r *http.Request) {
who := current(r)
_, _ = 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"`
Height int `json:"height"`
Gender int `json:"gender"`
}
if err := decode(r, &req); err != nil {
fail(w, 400, 20001, err.Error())
return
}
who := current(r)
_, err := a.db.ExecContext(r.Context(), `UPDATE user_profiles SET nickname=COALESCE(NULLIF(?,''),nickname),avatar_url=COALESCE(NULLIF(?,''),avatar_url),cover_url=COALESCE(NULLIF(?,''),cover_url),bio=?,city_name=COALESCE(NULLIF(?,''),city_name),height_cm=IF(?>0,?,height_cm),gender=IF(?>0,?,gender),profile_score=GREATEST(profile_score,80) WHERE user_id=?`, req.Nickname, req.Avatar, req.Cover, req.Bio, req.City, req.Height, req.Height, req.Gender, req.Gender, who.ID)
if err != nil {
fail(w, 500, 50001, "保存失败")
return
}
a.me(w, r)
}
func nullableString(v sql.NullString) string {
if v.Valid {
return v.String
}
return ""
}
+56
View File
@@ -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<NOW(3) LIMIT 1000`)
}
return hits <= limit
}
func (a *App) rateLimit(w http.ResponseWriter, r *http.Request, action, subject string, limit int, window time.Duration) bool {
if a.allowRequest(r.Context(), action, subject, limit, window) {
return true
}
w.Header().Set("Retry-After", fmt.Sprintf("%d", int(window/time.Second)))
fail(w, http.StatusTooManyRequests, 20002, "请求过于频繁,请稍后再试")
return false
}
func clientIP(r *http.Request) string {
if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); net.ParseIP(forwarded) != nil {
return forwarded
}
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); net.ParseIP(realIP) != nil {
return realIP
}
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err == nil && net.ParseIP(host) != nil {
return host
}
return "unknown"
}
+88
View File
@@ -0,0 +1,88 @@
package app
import (
"bytes"
"testing"
)
func productionConfigForTest() Config {
return Config{
Port: 8888,
DSN: "xingyu:password@tcp(mysql:3306)/im",
JWTSecret: "0123456789abcdef0123456789abcdef",
ConfigEncryptionKey: "abcdef0123456789abcdef0123456789",
Environment: "production",
AllowedOrigins: []string{"https://app.example.com", "https://admin.example.com"},
}
}
func TestValidateProductionConfig(t *testing.T) {
config := productionConfigForTest()
if err := validateConfig(config); err != nil {
t.Fatalf("expected a valid production config: %v", err)
}
tests := []struct {
name string
mutate func(*Config)
}{
{"root database account", func(config *Config) { config.DSN = "root:root@tcp(mysql:3306)/im" }},
{"weak jwt", func(config *Config) { config.JWTSecret = "short" }},
{"missing encryption key", func(config *Config) { config.ConfigEncryptionKey = "" }},
{"wildcard cors", func(config *Config) { config.AllowedOrigins = []string{"*"} }},
{"insecure origin", func(config *Config) { config.AllowedOrigins = []string{"http://app.example.com"} }},
{"demo seed", func(config *Config) { config.SeedDemo = true }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
invalid := config
test.mutate(&invalid)
if err := validateConfig(invalid); err == nil {
t.Fatal("expected production validation to reject config")
}
})
}
}
func TestPhoneEncryptionRoundTrip(t *testing.T) {
application := &App{config: productionConfigForTest()}
phone := "13800138000"
first, err := application.encryptPhone(phone)
if err != nil {
t.Fatal(err)
}
second, err := application.encryptPhone(phone)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(first, second) {
t.Fatal("phone encryption must use a unique random nonce")
}
decrypted, err := application.decryptPhone(first)
if err != nil || decrypted != phone {
t.Fatalf("unexpected decrypted phone %q: %v", decrypted, err)
}
}
func TestProductionURLValidation(t *testing.T) {
if !validHTTPSURL("https://pay.example.com/create") {
t.Fatal("expected HTTPS URL to be accepted")
}
for _, value := range []string{"http://pay.example.com", "javascript:alert(1)", "https:///missing-host", ""} {
if validHTTPSURL(value) {
t.Fatalf("expected URL to be rejected: %s", value)
}
}
}
func TestPasswordAndPhoneRules(t *testing.T) {
if !validUserPassword("Password123") || validUserPassword("12345678") || validUserPassword("password") {
t.Fatal("password policy is not enforced")
}
if !strongAdminPassword("AdminPassword@123") || strongAdminPassword("Password123") || strongAdminPassword("adminpassword@123") {
t.Fatal("admin password policy is not enforced")
}
if !validPhone("13800138000") || validPhone("23800138000") || validPhone("1380013800x") {
t.Fatal("phone policy is not enforced")
}
}
+189
View File
@@ -0,0 +1,189 @@
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 为空")
}
if a.config.Environment == "production" && !strongAdminPassword(adminPassword) {
return fmt.Errorf("生产管理员初始密码不符合强度要求")
}
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
}
}
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[:] }
+416
View File
@@ -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), &params)
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
}
@@ -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)
}
}
}
+399
View File
@@ -0,0 +1,399 @@
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"`
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"`
Bio string `json:"bio"`
VIP bool `json:"vip"`
VIPLevel int `json:"vipLevel"`
Online bool `json:"online"`
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
err := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.public_id,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,
(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 WHERE u.id=? AND u.status=1`, viewerID, viewerID, id).Scan(
&item.ID, &item.PublicID, &item.Nickname, &item.Avatar, &item.Cover, &item.Gender, &birthday, &item.Height, &item.City, &item.Bio, &vip, &item.VIPLevel, &active, &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.Online = active.Valid && time.Since(active.Time) < 15*time.Minute
item.Tags = []string{"摄影爱好者", "旅行达人", "天秤座"}
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 {
_, _ = 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")
query := `SELECT u.id,u.public_id,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,
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)
FROM users u JOIN user_profiles p ON p.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=?))`
args := []any{who.ID, who.ID, who.ID, who.ID, who.ID}
if scope == "following" {
query += ` AND EXISTS(SELECT 1 FROM user_follows mine WHERE mine.user_id=? AND mine.target_user_id=u.id)`
args = append(args, who.ID)
}
if gender > 0 {
query += ` AND p.gender=?`
args = append(args, gender)
}
if sortMode == "latest" {
query += ` ORDER BY p.last_active_at DESC,u.id DESC LIMIT 50`
} else {
query += ` ORDER BY p.is_vip DESC,p.last_active_at DESC LIMIT 50`
}
rows, err := a.db.QueryContext(r.Context(), query, args...)
if err != nil {
fail(w, 500, 50001, err.Error())
return
}
defer rows.Close()
items := []profileView{}
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)
for rows.Next() {
var item profileView
var birthday sql.NullTime
var active sql.NullTime
var lat, lng sql.NullFloat64
var vip int
if err := rows.Scan(&item.ID, &item.PublicID, &item.Nickname, &item.Avatar, &item.Cover, &item.Gender, &birthday, &item.Height, &item.City, &item.Bio, &vip, &item.VIPLevel, &active, &lat, &lng, &item.Following, &item.Liked); err != nil {
continue
}
item.VIP = vip == 1
if birthday.Valid {
item.Age = age(birthday.Time)
}
item.Online = active.Valid && time.Since(active.Time) < 15*time.Minute
if 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)
}
if byDistance {
sortProfilesByDistance(items)
}
reply(w, map[string]any{"items": items, "total": len(items)})
}
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 == 0 || req.Longitude == 0 {
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 {
if table == "user_likes" {
_, err = a.db.ExecContext(r.Context(), `INSERT IGNORE INTO user_likes (user_id,target_user_id,source) VALUES (?,?,'profile')`, current(r).ID, target)
} 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
}
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"`
}
func (a *App) feed(w http.ResponseWriter, r *http.Request) {
who := current(r)
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 r.URL.Query().Get("scope") == "following" {
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 50`
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
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": len(items)})
}
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 || strings.TrimSpace(req.Content) == "" {
fail(w, 400, 20001, "请输入动态内容")
return
}
if req.Visibility != 2 {
req.Visibility = 1
}
tx, _ := a.db.BeginTx(r.Context(), nil)
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 {
_, _ = tx.ExecContext(r.Context(), `INSERT INTO post_media(post_id,media_url,sort_order)VALUES(?,?,?)`, id, url, i)
}
_ = tx.Commit()
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, _ := a.db.BeginTx(r.Context(), nil)
if create {
result, _ := tx.ExecContext(r.Context(), `INSERT IGNORE INTO post_likes(post_id,user_id)VALUES(?,?)`, id, current(r).ID)
affected, _ := result.RowsAffected()
if affected > 0 {
_, _ = tx.ExecContext(r.Context(), `UPDATE posts SET like_count=like_count+1 WHERE id=?`, id)
}
} else {
result, _ := tx.ExecContext(r.Context(), `DELETE FROM post_likes WHERE post_id=? AND user_id=?`, id, current(r).ID)
affected, _ := result.RowsAffected()
if affected > 0 {
_, _ = tx.ExecContext(r.Context(), `UPDATE posts SET like_count=GREATEST(like_count-1,0) WHERE id=?`, id)
}
}
_ = tx.Commit()
reply(w, map[string]bool{"success": true})
}
func (a *App) comments(w http.ResponseWriter, r *http.Request) {
id, _ := pathID(r)
rows, err := a.db.QueryContext(r.Context(), `SELECT c.id,c.content,c.created_at,p.user_id,p.nickname,p.avatar_url FROM post_comments c JOIN user_profiles p ON p.user_id=c.user_id WHERE c.post_id=? AND c.status=1 ORDER BY c.created_at`, id)
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 string
var created time.Time
_ = rows.Scan(&cid, &content, &created, &uid, &nick, &avatar)
items = append(items, map[string]any{"id": cid, "content": content, "createdAt": created, "user": map[string]any{"id": uid, "nickname": nick, "avatar": avatar}})
}
reply(w, map[string]any{"items": items})
}
func (a *App) createComment(w http.ResponseWriter, r *http.Request) {
id, _ := pathID(r)
var req struct {
Content string `json:"content"`
}
if decode(r, &req) != nil || req.Content == "" {
fail(w, 400, 20001, "评论不能为空")
return
}
tx, _ := a.db.BeginTx(r.Context(), nil)
result, err := tx.ExecContext(r.Context(), `INSERT INTO post_comments(post_id,user_id,content)VALUES(?,?,?)`, id, current(r).ID, req.Content)
if err != nil {
_ = tx.Rollback()
fail(w, 500, 50001, "评论失败")
return
}
_, _ = tx.ExecContext(r.Context(), `UPDATE posts SET comment_count=comment_count+1 WHERE id=?`, id)
_ = tx.Commit()
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"`
}
if decode(r, &req) != nil || req.TargetID == 0 {
fail(w, 400, 20001, "举报信息不完整")
return
}
result, err := a.db.ExecContext(r.Context(), `INSERT INTO reports(reporter_user_id,target_type,target_id,reason_code,description)VALUES(?,?,?,?,?)`, current(r).ID, req.TargetType, req.TargetID, req.Reason, req.Description)
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 }
@@ -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
}
}
@@ -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)
}
}
+344
View File
@@ -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<DATE_SUB(NOW(3),INTERVAL 1 DAY) LIMIT 500`)
_, _ = a.db.ExecContext(ctx, `DELETE FROM user_oauth_login_codes WHERE expires_at<DATE_SUB(NOW(3),INTERVAL 1 DAY) LIMIT 500`)
}
+123
View File
@@ -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;
+65
View File
@@ -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;
+72
View File
@@ -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;
+68
View File
@@ -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;
+80
View File
@@ -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;
+72
View File
@@ -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;
+70
View File
@@ -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);
@@ -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);
@@ -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 = '????';
@@ -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));
@@ -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;
@@ -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;
@@ -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;
@@ -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);
@@ -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);
@@ -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;
@@ -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';
@@ -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);
@@ -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;
@@ -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);
@@ -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);
+24
View File
@@ -0,0 +1,24 @@
syntax = "proto3";
package im.v1;
option go_package = "github.com/example/xingyu/proto/im/v1;imv1";
enum Command {
COMMAND_UNKNOWN = 0;
AUTH = 1; AUTH_ACK = 2; PING = 3; PONG = 4;
SEND_MESSAGE = 10; SEND_ACK = 11; MESSAGE_PUSH = 12; DELIVERY_ACK = 13;
READ = 14; READ_ACK = 15; RECALL = 16; RECALL_ACK = 17;
SYNC = 18; SYNC_ACK = 19; TYPING = 20; CONVERSATION_UPDATE = 21;
KICK = 22; ERROR = 99;
}
message Envelope {
uint32 version = 1;
Command command = 2;
uint64 request_id = 3;
uint64 user_id = 4;
string device_id = 5;
int64 timestamp_ms = 6;
bytes payload = 7;
string trace_id = 8;
}
+15
View File
@@ -0,0 +1,15 @@
syntax = "proto3";
package im.v1;
option go_package = "github.com/example/xingyu/proto/im/v1;imv1";
enum MessageType { MESSAGE_TYPE_UNKNOWN = 0; TEXT = 1; IMAGE = 2; VOICE = 3; VIDEO = 4; LOCATION = 5; SYSTEM = 10; RECALL_NOTICE = 11; }
message TextContent { string text = 1; repeated uint64 mention_user_ids = 2; }
message ImageContent { uint64 media_id = 1; uint32 width = 2; uint32 height = 3; }
message VoiceContent { uint64 media_id = 1; uint32 duration_ms = 2; }
message Message { uint64 message_id = 1; string client_msg_id = 2; uint64 conversation_id = 3; uint64 seq = 4; uint64 sender_id = 5; MessageType type = 6; bytes content = 7; uint64 reply_to_message_id = 8; int64 created_at_ms = 9; }
message SendMessageRequest { string client_msg_id = 1; uint64 conversation_id = 2; MessageType type = 3; bytes content = 4; uint64 reply_to_message_id = 5; }
message SendMessageAck { string client_msg_id = 1; uint64 message_id = 2; uint64 conversation_id = 3; uint64 seq = 4; int64 server_time_ms = 5; }
message MessagePush { Message message = 1; string origin_device_id = 2; }
message ReadRequest { uint64 conversation_id = 1; uint64 read_seq = 2; }
message ReadAck { uint64 conversation_id = 1; uint64 user_id = 2; uint64 read_seq = 3; }
+7
View File
@@ -0,0 +1,7 @@
syntax = "proto3";
package im.v1;
option go_package = "github.com/example/xingyu/proto/im/v1;imv1";
message SyncRequest { uint64 last_sync_seq = 1; uint32 limit = 2; }
message SyncEvent { uint64 sync_seq = 1; uint32 event_type = 2; uint64 conversation_id = 3; uint64 message_seq = 4; bytes payload = 5; }
message SyncResponse { repeated SyncEvent events = 1; uint64 next_sync_seq = 2; bool has_more = 3; }