gengx
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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, ¤cy, &orderStatus, &channel, &paid, &created)
|
||||
items = append(items, map[string]any{"id": id, "orderNo": orderNo, "userId": userID, "publicId": publicID, "nickname": nickname, "productId": productID, "productName": product, "amountCent": amount, "currency": currency, "status": orderStatus, "channel": channel, "paidAt": paid, "createdAt": created})
|
||||
}
|
||||
reply(w, pageResult{Items: items, Total: total, Page: page, Size: size})
|
||||
}
|
||||
func (a *App) adminRefund(w http.ResponseWriter, r *http.Request) {
|
||||
if a.configPlain(r.Context(), "payment.mode", "sandbox") == "live" {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "订单编号无效")
|
||||
return
|
||||
}
|
||||
a.requestLiveRefund(w, r, id)
|
||||
return
|
||||
}
|
||||
a.adminOrderTransition(w, r, "refund")
|
||||
}
|
||||
|
||||
func (a *App) adminConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT config_key,config_value,value_type,description,updated_at FROM system_configs WHERE config_key NOT LIKE 'sms.%' AND config_key NOT LIKE 'payment.%' AND config_key NOT LIKE 'storage.%' AND config_key NOT LIKE 'oauth.%' ORDER BY config_key`)
|
||||
if err != nil {
|
||||
fail(w, 500, 50001, "查询失败")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var key, value, typ, description string
|
||||
var updated time.Time
|
||||
_ = rows.Scan(&key, &value, &typ, &description, &updated)
|
||||
if typ == "secret" && value != "" {
|
||||
value = maskedSecret
|
||||
}
|
||||
items = append(items, map[string]any{"key": key, "value": value, "type": typ, "description": description, "updatedAt": updated})
|
||||
}
|
||||
reply(w, map[string]any{"items": items})
|
||||
}
|
||||
func (a *App) adminUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
key, _ := url.PathUnescape(parts[len(parts)-1])
|
||||
var req struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if decode(r, &req) != nil {
|
||||
fail(w, 400, 20001, "invalid value")
|
||||
return
|
||||
}
|
||||
var valueType string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT value_type FROM system_configs WHERE config_key=?`, key).Scan(&valueType); err != nil {
|
||||
fail(w, 404, 30001, "配置项不存在")
|
||||
return
|
||||
}
|
||||
if valueType == "secret" {
|
||||
if req.Value == maskedSecret || req.Value == "" {
|
||||
reply(w, map[string]bool{"success": true})
|
||||
return
|
||||
}
|
||||
encrypted, err := a.encryptSecret(req.Value)
|
||||
if err != nil {
|
||||
fail(w, 500, 50001, "加密敏感配置失败")
|
||||
return
|
||||
}
|
||||
req.Value = encrypted
|
||||
}
|
||||
_, err := a.db.ExecContext(r.Context(), `UPDATE system_configs SET config_value=? WHERE config_key=?`, req.Value, key)
|
||||
if err != nil {
|
||||
fail(w, 500, 50001, "保存失败")
|
||||
return
|
||||
}
|
||||
a.audit(r, "update", "system_config", 0, map[string]any{"key": key, "secret": valueType == "secret"})
|
||||
reply(w, map[string]bool{"success": true})
|
||||
}
|
||||
func (a *App) adminAuditLogs(w http.ResponseWriter, r *http.Request) {
|
||||
page, size, offset := pagination(r)
|
||||
var total int64
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM admin_audit_logs`).Scan(&total)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT l.id,l.admin_user_id,a.real_name,l.action,l.target_type,l.target_id,l.request_data,l.ip,l.created_at FROM admin_audit_logs l JOIN admin_users a ON a.id=l.admin_user_id ORDER BY l.created_at DESC LIMIT ? OFFSET ?`, size, offset)
|
||||
if err != nil {
|
||||
fail(w, 500, 50001, "查询失败")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, adminID int64
|
||||
var name, action, targetType, ip string
|
||||
var targetID any
|
||||
var data []byte
|
||||
var created time.Time
|
||||
_ = rows.Scan(&id, &adminID, &name, &action, &targetType, &targetID, &data, &ip, &created)
|
||||
var request any
|
||||
_ = json.Unmarshal(data, &request)
|
||||
items = append(items, map[string]any{"id": id, "adminId": adminID, "adminName": name, "action": action, "targetType": targetType, "targetId": targetID, "request": request, "ip": ip, "createdAt": created})
|
||||
}
|
||||
reply(w, pageResult{Items: items, Total: total, Page: page, Size: size})
|
||||
}
|
||||
|
||||
func (a *App) audit(r *http.Request, action, targetType string, targetID int64, data any) {
|
||||
payload, _ := json.Marshal(data)
|
||||
var id any
|
||||
if targetID > 0 {
|
||||
id = targetID
|
||||
}
|
||||
_, _ = a.db.ExecContext(r.Context(), `INSERT INTO admin_audit_logs(admin_user_id,action,target_type,target_id,request_data,ip)VALUES(?,?,?,?,?,?)`, current(r).ID, action, targetType, id, payload, clientIP(r))
|
||||
}
|
||||
func maskPhone(value string) string {
|
||||
if len(value) >= 7 {
|
||||
return value[:3] + "****" + value[len(value)-4:]
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maskedSecret = "••••••••"
|
||||
|
||||
type integrationFieldSpec struct {
|
||||
Key string
|
||||
Label string
|
||||
Input string
|
||||
Required bool
|
||||
Options []string
|
||||
Providers []string
|
||||
Description string
|
||||
}
|
||||
|
||||
type integrationFieldView struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
Input string `json:"input"`
|
||||
Secret bool `json:"secret"`
|
||||
HasValue bool `json:"hasValue"`
|
||||
Required bool `json:"required"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
Providers []string `json:"providers,omitempty"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
var integrationSpecs = map[string][]integrationFieldSpec{
|
||||
"oauth": {
|
||||
{Key: "oauth.admin.frontend_callback_url", Label: "管理端登录结果页", Input: "text", Required: true, Description: "第三方授权完成后返回的管理端页面;生产环境必须使用 HTTPS"},
|
||||
{Key: "oauth.user.frontend_callback_url", Label: "客户端 H5 登录结果页", Input: "text", Required: true, Description: "第三方授权完成后返回的 uni-app H5 页面;生产环境必须使用 HTTPS"},
|
||||
|
||||
{Key: "oauth.wechat.enabled", Label: "管理端启用微信登录", Input: "boolean", Required: true, Providers: []string{"wechat"}, Description: "开启后且必填参数完整时,管理端登录页显示微信入口"},
|
||||
{Key: "oauth.user.wechat.enabled", Label: "客户端启用微信登录", Input: "boolean", Required: true, Providers: []string{"wechat"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示微信入口"},
|
||||
{Key: "oauth.wechat.client_id", Label: "微信 AppID", Input: "text", Providers: []string{"wechat"}, Description: "微信开放平台网站应用 AppID"},
|
||||
{Key: "oauth.wechat.client_secret", Label: "微信 AppSecret", Input: "secret", Providers: []string{"wechat"}, Description: "AES-GCM 加密保存"},
|
||||
{Key: "oauth.wechat.authorization_url", Label: "微信授权地址", Input: "text", Providers: []string{"wechat"}, Description: "默认使用微信开放平台 qrconnect 地址"},
|
||||
{Key: "oauth.wechat.token_url", Label: "微信令牌地址", Input: "text", Providers: []string{"wechat"}, Description: "授权码换取 access_token 的地址"},
|
||||
{Key: "oauth.wechat.userinfo_url", Label: "微信用户信息地址", Input: "text", Providers: []string{"wechat"}, Description: "获取登录用户 OpenID 与资料的地址"},
|
||||
{Key: "oauth.wechat.scope", Label: "微信授权范围", Input: "text", Providers: []string{"wechat"}, Description: "网站扫码登录通常为 snsapi_login"},
|
||||
{Key: "oauth.wechat.redirect_uri", Label: "微信回调地址", Input: "text", Providers: []string{"wechat"}, Description: "必须与微信开放平台登记值完全一致"},
|
||||
|
||||
{Key: "oauth.qq.enabled", Label: "管理端启用 QQ 登录", Input: "boolean", Required: true, Providers: []string{"qq"}, Description: "开启后且必填参数完整时,管理端登录页显示 QQ 入口"},
|
||||
{Key: "oauth.user.qq.enabled", Label: "客户端启用 QQ 登录", Input: "boolean", Required: true, Providers: []string{"qq"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示 QQ 入口"},
|
||||
{Key: "oauth.qq.client_id", Label: "QQ AppID", Input: "text", Providers: []string{"qq"}, Description: "QQ 互联应用 AppID"},
|
||||
{Key: "oauth.qq.client_secret", Label: "QQ AppKey", Input: "secret", Providers: []string{"qq"}, Description: "AES-GCM 加密保存"},
|
||||
{Key: "oauth.qq.authorization_url", Label: "QQ 授权地址", Input: "text", Providers: []string{"qq"}, Description: "QQ OAuth 2.0 authorize 地址"},
|
||||
{Key: "oauth.qq.token_url", Label: "QQ 令牌地址", Input: "text", Providers: []string{"qq"}, Description: "授权码换取 access_token 的地址"},
|
||||
{Key: "oauth.qq.openid_url", Label: "QQ OpenID 地址", Input: "text", Providers: []string{"qq"}, Description: "使用 access_token 获取 QQ OpenID"},
|
||||
{Key: "oauth.qq.userinfo_url", Label: "QQ 用户信息地址", Input: "text", Providers: []string{"qq"}, Description: "获取昵称和头像"},
|
||||
{Key: "oauth.qq.scope", Label: "QQ 授权范围", Input: "text", Providers: []string{"qq"}, Description: "默认 get_user_info"},
|
||||
{Key: "oauth.qq.redirect_uri", Label: "QQ 回调地址", Input: "text", Providers: []string{"qq"}, Description: "必须与 QQ 互联登记值完全一致"},
|
||||
|
||||
{Key: "oauth.github.enabled", Label: "管理端启用 GitHub 登录", Input: "boolean", Required: true, Providers: []string{"github"}, Description: "开启后且必填参数完整时,管理端登录页显示 GitHub 入口"},
|
||||
{Key: "oauth.user.github.enabled", Label: "客户端启用 GitHub 登录", Input: "boolean", Required: true, Providers: []string{"github"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示 GitHub 入口"},
|
||||
{Key: "oauth.github.client_id", Label: "GitHub Client ID", Input: "text", Providers: []string{"github"}, Description: "GitHub OAuth App Client ID"},
|
||||
{Key: "oauth.github.client_secret", Label: "GitHub Client Secret", Input: "secret", Providers: []string{"github"}, Description: "AES-GCM 加密保存"},
|
||||
{Key: "oauth.github.authorization_url", Label: "GitHub 授权地址", Input: "text", Providers: []string{"github"}, Description: "GitHub OAuth authorize 地址"},
|
||||
{Key: "oauth.github.token_url", Label: "GitHub 令牌地址", Input: "text", Providers: []string{"github"}, Description: "授权码换取 access_token 的地址"},
|
||||
{Key: "oauth.github.userinfo_url", Label: "GitHub 用户信息地址", Input: "text", Providers: []string{"github"}, Description: "默认使用 /user 接口"},
|
||||
{Key: "oauth.github.scope", Label: "GitHub 授权范围", Input: "text", Providers: []string{"github"}, Description: "建议仅 read:user user:email"},
|
||||
{Key: "oauth.github.redirect_uri", Label: "GitHub 回调地址", Input: "text", Providers: []string{"github"}, Description: "必须与 OAuth App 的 callback URL 完全一致"},
|
||||
|
||||
{Key: "oauth.google.enabled", Label: "管理端启用 Google 登录", Input: "boolean", Required: true, Providers: []string{"google"}, Description: "开启后且必填参数完整时,管理端登录页显示 Google 入口"},
|
||||
{Key: "oauth.user.google.enabled", Label: "客户端启用 Google 登录", Input: "boolean", Required: true, Providers: []string{"google"}, Description: "开启后且必填参数完整时,uni-app H5 登录页显示 Google 入口"},
|
||||
{Key: "oauth.google.client_id", Label: "Google Client ID", Input: "text", Providers: []string{"google"}, Description: "Google OAuth 2.0 Client ID"},
|
||||
{Key: "oauth.google.client_secret", Label: "Google Client Secret", Input: "secret", Providers: []string{"google"}, Description: "AES-GCM 加密保存"},
|
||||
{Key: "oauth.google.authorization_url", Label: "Google 授权地址", Input: "text", Providers: []string{"google"}, Description: "Google OAuth authorization endpoint"},
|
||||
{Key: "oauth.google.token_url", Label: "Google 令牌地址", Input: "text", Providers: []string{"google"}, Description: "Google OAuth token endpoint"},
|
||||
{Key: "oauth.google.userinfo_url", Label: "Google UserInfo 地址", Input: "text", Providers: []string{"google"}, Description: "OpenID Connect UserInfo endpoint"},
|
||||
{Key: "oauth.google.scope", Label: "Google 授权范围", Input: "text", Providers: []string{"google"}, Description: "至少包含 openid profile email"},
|
||||
{Key: "oauth.google.redirect_uri", Label: "Google 回调地址", Input: "text", Providers: []string{"google"}, Description: "必须与 Google Cloud Console 登记值完全一致"},
|
||||
},
|
||||
"storage": {
|
||||
{Key: "storage.provider", Label: "当前存储厂商", Input: "select", Required: true, Options: []string{"local", "aliyun_oss", "tencent_cos", "qiniu", "huawei_obs", "huawei_flexus"}, Description: "保存后所有新上传文件立即切换到所选存储;历史文件地址不受影响"},
|
||||
{Key: "storage.object_prefix", Label: "云端对象前缀", Input: "text", Required: true, Description: "仅允许字母、数字、斜杠、下划线和短横线,例如 media"},
|
||||
|
||||
{Key: "storage.local.directory", Label: "本地存储目录", Input: "text", Required: true, Providers: []string{"local"}, Description: "相对路径基于后端运行目录;禁止配置为磁盘根目录"},
|
||||
{Key: "storage.local.public_base_url", Label: "本地公开访问地址", Input: "text", Providers: []string{"local"}, Description: "可选,例如 https://api.example.com/uploads;留空时根据当前请求生成"},
|
||||
|
||||
{Key: "storage.aliyun_oss.endpoint", Label: "阿里云 OSS Endpoint", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "例如 https://oss-cn-hangzhou.aliyuncs.com"},
|
||||
{Key: "storage.aliyun_oss.region", Label: "阿里云 OSS Region", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "例如 cn-hangzhou"},
|
||||
{Key: "storage.aliyun_oss.bucket", Label: "阿里云 OSS Bucket", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "需要 oss:PutObject 权限"},
|
||||
{Key: "storage.aliyun_oss.access_key_id", Label: "阿里云 AccessKey ID", Input: "secret", Required: true, Providers: []string{"aliyun_oss"}, Description: "建议使用最小权限 RAM 用户,AES-GCM 加密保存"},
|
||||
{Key: "storage.aliyun_oss.access_key_secret", Label: "阿里云 AccessKey Secret", Input: "secret", Required: true, Providers: []string{"aliyun_oss"}, Description: "AES-GCM 加密保存"},
|
||||
{Key: "storage.aliyun_oss.public_base_url", Label: "阿里云文件访问域名", Input: "text", Required: true, Providers: []string{"aliyun_oss"}, Description: "Bucket 公网域名或已配置的 CDN/自定义域名,必须使用 HTTPS"},
|
||||
|
||||
{Key: "storage.tencent_cos.endpoint", Label: "腾讯云 COS Bucket URL", Input: "text", Required: true, Providers: []string{"tencent_cos"}, Description: "例如 https://bucket-appid.cos.ap-guangzhou.myqcloud.com"},
|
||||
{Key: "storage.tencent_cos.bucket", Label: "腾讯云 COS Bucket", Input: "text", Required: true, Providers: []string{"tencent_cos"}, Description: "完整名称需包含 APPID,需要 cos:PutObject 权限"},
|
||||
{Key: "storage.tencent_cos.secret_id", Label: "腾讯云 SecretId", Input: "secret", Required: true, Providers: []string{"tencent_cos"}, Description: "建议使用最小权限 CAM 子账号密钥,AES-GCM 加密保存"},
|
||||
{Key: "storage.tencent_cos.secret_key", Label: "腾讯云 SecretKey", Input: "secret", Required: true, Providers: []string{"tencent_cos"}, Description: "AES-GCM 加密保存"},
|
||||
{Key: "storage.tencent_cos.public_base_url", Label: "腾讯云文件访问域名", Input: "text", Required: true, Providers: []string{"tencent_cos"}, Description: "Bucket 公网域名或 CDN 域名,必须使用 HTTPS"},
|
||||
|
||||
{Key: "storage.qiniu.bucket", Label: "七牛云空间名称", Input: "text", Required: true, Providers: []string{"qiniu"}, Description: "Kodo Bucket 名称,SDK 自动发现上传区域"},
|
||||
{Key: "storage.qiniu.access_key", Label: "七牛云 AccessKey", Input: "secret", Required: true, Providers: []string{"qiniu"}, Description: "建议使用仅具备目标空间上传权限的密钥,AES-GCM 加密保存"},
|
||||
{Key: "storage.qiniu.secret_key", Label: "七牛云 SecretKey", Input: "secret", Required: true, Providers: []string{"qiniu"}, Description: "AES-GCM 加密保存"},
|
||||
{Key: "storage.qiniu.public_base_url", Label: "七牛云文件访问域名", Input: "text", Required: true, Providers: []string{"qiniu"}, Description: "空间绑定域名或 CDN 域名,必须使用 HTTPS"},
|
||||
|
||||
{Key: "storage.huawei_obs.endpoint", Label: "华为云 OBS Endpoint", Input: "text", Required: true, Providers: []string{"huawei_obs"}, Description: "例如 https://obs.cn-north-4.myhuaweicloud.com"},
|
||||
{Key: "storage.huawei_obs.bucket", Label: "华为云 OBS Bucket", Input: "text", Required: true, Providers: []string{"huawei_obs"}, Description: "需要 obs:object:PutObject 权限"},
|
||||
{Key: "storage.huawei_obs.access_key", Label: "华为云 OBS Access Key", Input: "secret", Required: true, Providers: []string{"huawei_obs"}, Description: "IAM 用户 AK,AES-GCM 加密保存"},
|
||||
{Key: "storage.huawei_obs.secret_key", Label: "华为云 OBS Secret Key", Input: "secret", Required: true, Providers: []string{"huawei_obs"}, Description: "IAM 用户 SK,AES-GCM 加密保存"},
|
||||
{Key: "storage.huawei_obs.public_base_url", Label: "华为云 OBS 文件访问域名", Input: "text", Required: true, Providers: []string{"huawei_obs"}, Description: "Bucket 公网域名或 CDN 域名,必须使用 HTTPS"},
|
||||
|
||||
{Key: "storage.huawei_flexus.endpoint", Label: "Flexus 对象存储 Endpoint", Input: "text", Required: true, Providers: []string{"huawei_flexus"}, Description: "Flexus 对象存储控制台提供的 OBS 兼容 Endpoint"},
|
||||
{Key: "storage.huawei_flexus.bucket", Label: "Flexus Bucket", Input: "text", Required: true, Providers: []string{"huawei_flexus"}, Description: "Flexus 对象存储桶名称"},
|
||||
{Key: "storage.huawei_flexus.access_key", Label: "Flexus Access Key", Input: "secret", Required: true, Providers: []string{"huawei_flexus"}, Description: "AES-GCM 加密保存"},
|
||||
{Key: "storage.huawei_flexus.secret_key", Label: "Flexus Secret Key", Input: "secret", Required: true, Providers: []string{"huawei_flexus"}, Description: "AES-GCM 加密保存"},
|
||||
{Key: "storage.huawei_flexus.public_base_url", Label: "Flexus 文件访问域名", Input: "text", Required: true, Providers: []string{"huawei_flexus"}, Description: "Bucket 公网域名或 CDN 域名,必须使用 HTTPS"},
|
||||
},
|
||||
"sms": {
|
||||
{Key: "sms.enabled", Label: "启用短信服务", Input: "boolean", Required: true, Description: "关闭后将拒绝发送验证码"},
|
||||
{Key: "sms.provider", Label: "当前短信厂商", Input: "select", Required: true, Options: []string{"aliyun", "tencent", "huawei", "webhook", "debug"}, Description: "保存后新验证码立即切换到所选厂商;debug 仅限本地开发"},
|
||||
{Key: "sms.expire_seconds", Label: "有效期(秒)", Input: "number", Required: true, Description: "建议 120 至 600 秒"},
|
||||
{Key: "sms.debug_code", Label: "调试验证码", Input: "secret", Required: true, Providers: []string{"debug"}, Description: "仅 debug 模式返回给客户端"},
|
||||
|
||||
{Key: "sms.aliyun.endpoint", Label: "阿里云 API 地址", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "国内短信默认 https://dysmsapi.aliyuncs.com"},
|
||||
{Key: "sms.aliyun.access_key_id", Label: "阿里云 AccessKey ID", Input: "secret", Required: true, Providers: []string{"aliyun"}, Description: "建议使用仅授予短信发送权限的 RAM 用户"},
|
||||
{Key: "sms.aliyun.access_key_secret", Label: "阿里云 AccessKey Secret", Input: "secret", Required: true, Providers: []string{"aliyun"}, Description: "AES-GCM 加密保存"},
|
||||
{Key: "sms.aliyun.sign_name", Label: "阿里云短信签名", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "必须是审核通过的签名名称"},
|
||||
{Key: "sms.aliyun.template_register", Label: "阿里云注册模板 Code", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "注册验证码模板,例如 SMS_123456789"},
|
||||
{Key: "sms.aliyun.template_login", Label: "阿里云登录模板 Code", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "短信登录验证码模板"},
|
||||
{Key: "sms.aliyun.template_reset", Label: "阿里云重置密码模板 Code", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "找回密码验证码模板"},
|
||||
{Key: "sms.aliyun.template_params", Label: "阿里云模板变量 JSON", Input: "text", Required: true, Providers: []string{"aliyun"}, Description: "支持 {{code}} 与 {{minutes}},例如 {\"code\":\"{{code}}\"}"},
|
||||
|
||||
{Key: "sms.tencent.endpoint", Label: "腾讯云 API 地址", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "国内短信默认 https://sms.tencentcloudapi.com"},
|
||||
{Key: "sms.tencent.secret_id", Label: "腾讯云 SecretId", Input: "secret", Required: true, Providers: []string{"tencent"}, Description: "建议使用最小权限 CAM 子账号密钥"},
|
||||
{Key: "sms.tencent.secret_key", Label: "腾讯云 SecretKey", Input: "secret", Required: true, Providers: []string{"tencent"}, Description: "AES-GCM 加密保存"},
|
||||
{Key: "sms.tencent.sdk_app_id", Label: "腾讯云短信 SdkAppId", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "短信控制台应用 ID"},
|
||||
{Key: "sms.tencent.region", Label: "腾讯云地域", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "国内短信建议 ap-guangzhou"},
|
||||
{Key: "sms.tencent.sign_name", Label: "腾讯云短信签名", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "必须是审核通过的签名内容"},
|
||||
{Key: "sms.tencent.template_register", Label: "腾讯云注册模板 ID", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "注册验证码模板 ID"},
|
||||
{Key: "sms.tencent.template_login", Label: "腾讯云登录模板 ID", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "短信登录验证码模板 ID"},
|
||||
{Key: "sms.tencent.template_reset", Label: "腾讯云重置密码模板 ID", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "找回密码验证码模板 ID"},
|
||||
{Key: "sms.tencent.template_params", Label: "腾讯云模板参数 JSON", Input: "text", Required: true, Providers: []string{"tencent"}, Description: "参数按模板变量顺序排列,例如 [\"{{code}}\"]"},
|
||||
|
||||
{Key: "sms.huawei.endpoint", Label: "华为云 APP 接入地址", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "填写控制台提供的 HTTPS 地址,包含 /sms/batchSendSms/v1"},
|
||||
{Key: "sms.huawei.app_key", Label: "华为云 Application Key", Input: "secret", Required: true, Providers: []string{"huawei"}, Description: "短信应用的 APP_Key"},
|
||||
{Key: "sms.huawei.app_secret", Label: "华为云 Application Secret", Input: "secret", Required: true, Providers: []string{"huawei"}, Description: "短信应用的 APP_Secret,AES-GCM 加密保存"},
|
||||
{Key: "sms.huawei.sender", Label: "华为云签名通道号", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "国内短信签名审核后分配的通道号"},
|
||||
{Key: "sms.huawei.signature", Label: "华为云签名名称", Input: "text", Providers: []string{"huawei"}, Description: "通用模板需要填写已审核签名;非通用模板可留空"},
|
||||
{Key: "sms.huawei.template_register", Label: "华为云注册模板 ID", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "注册验证码模板 ID"},
|
||||
{Key: "sms.huawei.template_login", Label: "华为云登录模板 ID", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "短信登录验证码模板 ID"},
|
||||
{Key: "sms.huawei.template_reset", Label: "华为云重置密码模板 ID", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "找回密码验证码模板 ID"},
|
||||
{Key: "sms.huawei.template_params", Label: "华为云模板参数 JSON", Input: "text", Required: true, Providers: []string{"huawei"}, Description: "参数按模板变量顺序排列,例如 [\"{{code}}\"]"},
|
||||
{Key: "sms.huawei.status_callback", Label: "华为云状态回调地址", Input: "text", Providers: []string{"huawei"}, Description: "可选,接收运营商最终送达状态"},
|
||||
|
||||
{Key: "sms.sign_name", Label: "Webhook 短信签名", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "发送给自建网关的签名名称"},
|
||||
{Key: "sms.template_register", Label: "Webhook 注册模板 ID", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "注册场景模板"},
|
||||
{Key: "sms.template_login", Label: "Webhook 登录模板 ID", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "验证码登录场景模板"},
|
||||
{Key: "sms.template_reset", Label: "Webhook 找回密码模板 ID", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "重置密码场景模板"},
|
||||
{Key: "sms.webhook_url", Label: "Webhook 地址", Input: "text", Required: true, Providers: []string{"webhook"}, Description: "接收 JSON POST;生产环境必须使用 HTTPS"},
|
||||
{Key: "sms.webhook_token", Label: "Webhook Token", Input: "secret", Providers: []string{"webhook"}, Description: "以 Bearer Token 发送,仅显示保存状态"},
|
||||
},
|
||||
"payment": {
|
||||
{Key: "payment.mode", Label: "支付模式", Input: "select", Required: true, Options: []string{"sandbox", "live"}, Description: "sandbox 可直接完成本地支付闭环"},
|
||||
{Key: "payment.gateway.create_url", Label: "支付网关下单地址", Input: "text", Description: "live 模式必填,生产环境必须使用 HTTPS"},
|
||||
{Key: "payment.gateway.refund_url", Label: "支付网关退款地址", Input: "text", Description: "live 模式退款必填,生产环境必须使用 HTTPS"},
|
||||
{Key: "payment.gateway.token", Label: "支付网关访问令牌", Input: "secret", Description: "live 模式必填,以 Bearer Token 调用统一支付网关"},
|
||||
{Key: "payment.gateway.notify_secret", Label: "支付回调签名密钥", Input: "secret", Description: "live 模式必填,至少 32 位随机字符串,用于 HMAC-SHA256 验签"},
|
||||
{Key: "payment.gateway.notify_url", Label: "支付异步回调地址", Input: "text", Description: "live 模式必填,例如 https://api.example.com/api/v1/payment/notify"},
|
||||
{Key: "payment.gateway.return_url", Label: "支付完成返回地址", Input: "text", Description: "H5 支付完成后返回的客户端地址"},
|
||||
{Key: "payment.gateway.timeout_seconds", Label: "网关超时(秒)", Input: "number", Description: "建议 5 至 30 秒"},
|
||||
{Key: "payment.alipay.enabled", Label: "启用支付宝", Input: "boolean", Required: true, Description: "控制客户端支付宝入口"},
|
||||
{Key: "payment.alipay.app_id", Label: "支付宝 APPID", Input: "text", Description: "开放平台应用 APPID"},
|
||||
{Key: "payment.alipay.private_key", Label: "支付宝应用私钥", Input: "secret", Description: "敏感字段加密存储"},
|
||||
{Key: "payment.alipay.public_key", Label: "支付宝公钥", Input: "secret", Description: "用于验签"},
|
||||
{Key: "payment.alipay.notify_url", Label: "支付宝通知地址", Input: "text", Description: "必须是公网 HTTPS 地址"},
|
||||
{Key: "payment.wechat.enabled", Label: "启用微信支付", Input: "boolean", Required: true, Description: "控制客户端微信支付入口"},
|
||||
{Key: "payment.wechat.app_id", Label: "微信 AppID", Input: "text", Description: "移动应用或小程序 AppID"},
|
||||
{Key: "payment.wechat.mch_id", Label: "微信商户号", Input: "text", Description: "微信支付商户号"},
|
||||
{Key: "payment.wechat.api_v3_key", Label: "APIv3 密钥", Input: "secret", Description: "敏感字段加密存储"},
|
||||
{Key: "payment.wechat.private_key", Label: "商户私钥", Input: "secret", Description: "PEM 内容,敏感字段加密存储"},
|
||||
{Key: "payment.wechat.serial_no", Label: "证书序列号", Input: "text", Description: "商户 API 证书序列号"},
|
||||
{Key: "payment.wechat.notify_url", Label: "微信通知地址", Input: "text", Description: "必须是公网 HTTPS 地址"},
|
||||
},
|
||||
}
|
||||
|
||||
func (a *App) configPlain(ctx context.Context, key, fallback string) string {
|
||||
var value, valueType string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT config_value,value_type FROM system_configs WHERE config_key=?`, key).Scan(&value, &valueType); err != nil {
|
||||
return fallback
|
||||
}
|
||||
if valueType == "secret" && value != "" {
|
||||
plain, err := a.decryptSecret(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return plain
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (a *App) configBool(ctx context.Context, key string, fallback bool) bool {
|
||||
value := strings.ToLower(a.configPlain(ctx, key, strconv.FormatBool(fallback)))
|
||||
return value == "1" || value == "true" || value == "yes" || value == "on"
|
||||
}
|
||||
|
||||
func (a *App) encryptSecret(plain string) (string, error) {
|
||||
if plain == "" {
|
||||
return "", nil
|
||||
}
|
||||
key := sha256.Sum256([]byte(a.integrationEncryptionKey()))
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := gcm.Seal(nonce, nonce, []byte(plain), nil)
|
||||
return "enc:v1:" + base64.RawStdEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
func (a *App) decryptSecret(value string) (string, error) {
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !strings.HasPrefix(value, "enc:v1:") {
|
||||
return value, nil
|
||||
}
|
||||
encoded := strings.TrimPrefix(value, "enc:v1:")
|
||||
payload, err := base64.RawStdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := sha256.Sum256([]byte(a.integrationEncryptionKey()))
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil || len(payload) < gcm.NonceSize() {
|
||||
return "", fmt.Errorf("invalid encrypted secret")
|
||||
}
|
||||
returnValue, err := gcm.Open(nil, payload[:gcm.NonceSize()], payload[gcm.NonceSize():], nil)
|
||||
if err == nil {
|
||||
return string(returnValue), nil
|
||||
}
|
||||
if a.config.ConfigEncryptionKey != "" {
|
||||
legacyKey := sha256.Sum256([]byte(a.config.JWTSecret + ":integration-config"))
|
||||
legacyBlock, legacyErr := aes.NewCipher(legacyKey[:])
|
||||
if legacyErr == nil {
|
||||
legacyGCM, legacyErr := cipher.NewGCM(legacyBlock)
|
||||
if legacyErr == nil && len(payload) >= legacyGCM.NonceSize() {
|
||||
returnValue, legacyErr = legacyGCM.Open(nil, payload[:legacyGCM.NonceSize()], payload[legacyGCM.NonceSize():], nil)
|
||||
if legacyErr == nil {
|
||||
return string(returnValue), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
func (a *App) integrationEncryptionKey() string {
|
||||
if a.config.ConfigEncryptionKey != "" {
|
||||
return a.config.ConfigEncryptionKey
|
||||
}
|
||||
// Development-only compatibility for databases created before the dedicated key existed.
|
||||
return a.config.JWTSecret + ":integration-config"
|
||||
}
|
||||
|
||||
func integrationGroup(r *http.Request) string {
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
for index, part := range parts {
|
||||
if part == "integrations" && index+1 < len(parts) {
|
||||
return parts[index+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *App) adminIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
group := integrationGroup(r)
|
||||
specs, ok := integrationSpecs[group]
|
||||
if !ok {
|
||||
fail(w, http.StatusNotFound, 30001, "配置分组不存在")
|
||||
return
|
||||
}
|
||||
fields := make([]integrationFieldView, 0, len(specs))
|
||||
configured := true
|
||||
activeProvider := ""
|
||||
if group == "sms" {
|
||||
activeProvider = a.configPlain(r.Context(), "sms.provider", "debug")
|
||||
configured = a.configBool(r.Context(), "sms.enabled", false)
|
||||
} else if group == "storage" {
|
||||
activeProvider = a.configPlain(r.Context(), "storage.provider", "local")
|
||||
} else if group == "oauth" {
|
||||
configured = a.oauthConfigurationReady(r.Context())
|
||||
}
|
||||
for _, spec := range specs {
|
||||
var value, valueType string
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT config_value,value_type FROM system_configs WHERE config_key=?`, spec.Key).Scan(&value, &valueType)
|
||||
secret := valueType == "secret" || spec.Input == "secret"
|
||||
hasValue := value != ""
|
||||
displayValue := value
|
||||
if secret {
|
||||
displayValue = ""
|
||||
}
|
||||
applies := len(spec.Providers) == 0 || containsString(spec.Providers, activeProvider)
|
||||
if group == "oauth" {
|
||||
applies = false // OAuth 必填项由各渠道的启用状态独立校验。
|
||||
}
|
||||
if applies && spec.Required && !hasValue {
|
||||
configured = false
|
||||
}
|
||||
fields = append(fields, integrationFieldView{Key: spec.Key, Label: spec.Label, Value: displayValue, Input: spec.Input, Secret: secret, HasValue: hasValue, Required: spec.Required, Options: spec.Options, Providers: spec.Providers, Description: spec.Description})
|
||||
}
|
||||
reply(w, map[string]any{"group": group, "configured": configured, "fields": fields, "secretMask": maskedSecret})
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) adminUpdateIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
group := integrationGroup(r)
|
||||
specs, ok := integrationSpecs[group]
|
||||
if !ok {
|
||||
fail(w, http.StatusNotFound, 30001, "配置分组不存在")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Values map[string]string `json:"values"`
|
||||
ClearSecrets []string `json:"clearSecrets"`
|
||||
}
|
||||
if decode(r, &req) != nil || req.Values == nil {
|
||||
fail(w, http.StatusBadRequest, 20001, "配置格式错误")
|
||||
return
|
||||
}
|
||||
allowed := map[string]integrationFieldSpec{}
|
||||
for _, spec := range specs {
|
||||
allowed[spec.Key] = spec
|
||||
}
|
||||
for key, value := range req.Values {
|
||||
spec, exists := allowed[key]
|
||||
if !exists {
|
||||
fail(w, http.StatusBadRequest, 20001, "配置项不允许修改")
|
||||
return
|
||||
}
|
||||
switch spec.Input {
|
||||
case "select":
|
||||
if !containsString(spec.Options, value) {
|
||||
fail(w, http.StatusBadRequest, 20001, spec.Label+"选项无效")
|
||||
return
|
||||
}
|
||||
case "boolean":
|
||||
if value != "true" && value != "false" {
|
||||
fail(w, http.StatusBadRequest, 20001, spec.Label+"必须为 true 或 false")
|
||||
return
|
||||
}
|
||||
case "number":
|
||||
number, parseErr := strconv.Atoi(value)
|
||||
if parseErr != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, spec.Label+"必须是数字")
|
||||
return
|
||||
}
|
||||
if key == "sms.expire_seconds" && (number < 60 || number > 1800) {
|
||||
fail(w, http.StatusBadRequest, 20001, "短信有效期必须在 60 到 1800 秒之间")
|
||||
return
|
||||
}
|
||||
if key == "payment.gateway.timeout_seconds" && (number < 3 || number > 30) {
|
||||
fail(w, http.StatusBadRequest, 20001, "支付网关超时必须在 3 到 30 秒之间")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
clearSet := map[string]bool{}
|
||||
for _, key := range req.ClearSecrets {
|
||||
spec, exists := allowed[key]
|
||||
if !exists || spec.Input != "secret" {
|
||||
fail(w, http.StatusBadRequest, 20001, "清除的配置项不是允许的密钥字段")
|
||||
return
|
||||
}
|
||||
clearSet[key] = true
|
||||
}
|
||||
if group == "oauth" {
|
||||
if err := a.validateAdminOAuthConfigValues(r.Context(), req.Values, clearSet); err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
fail(w, 500, 50001, "保存失败")
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
changed := []string{}
|
||||
for key, value := range req.Values {
|
||||
spec, exists := allowed[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
isSecret := spec.Input == "secret"
|
||||
if isSecret && value == "" && !clearSet[key] {
|
||||
continue
|
||||
}
|
||||
if isSecret && !clearSet[key] {
|
||||
value, err = a.encryptSecret(value)
|
||||
if err != nil {
|
||||
fail(w, 500, 50001, "加密敏感配置失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
if clearSet[key] {
|
||||
value = ""
|
||||
}
|
||||
if _, err = tx.ExecContext(r.Context(), `UPDATE system_configs SET config_value=? WHERE config_key=?`, value, key); err != nil {
|
||||
fail(w, 500, 50001, "保存失败")
|
||||
return
|
||||
}
|
||||
changed = append(changed, key)
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
fail(w, 500, 50001, "保存失败")
|
||||
return
|
||||
}
|
||||
a.audit(r, "update", group+"_integration", 0, map[string]any{"changedKeys": changed})
|
||||
reply(w, map[string]any{"success": true, "changedKeys": changed})
|
||||
}
|
||||
|
||||
func (a *App) adminTestIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
group := integrationGroup(r)
|
||||
switch group {
|
||||
case "sms":
|
||||
if !a.configBool(r.Context(), "sms.enabled", false) {
|
||||
fail(w, 400, 20001, "短信服务当前未启用")
|
||||
return
|
||||
}
|
||||
provider := a.configPlain(r.Context(), "sms.provider", "debug")
|
||||
if err := a.validateSMSProviderConfig(r.Context()); err != nil {
|
||||
fail(w, 400, 20001, err.Error())
|
||||
return
|
||||
}
|
||||
reply(w, map[string]any{"success": true, "message": cloudSMSProviderName(provider) + "短信配置校验通过", "provider": provider})
|
||||
case "payment":
|
||||
mode := a.configPlain(r.Context(), "payment.mode", "sandbox")
|
||||
if a.config.Environment == "production" && mode != "live" {
|
||||
fail(w, 400, 20001, "生产环境必须使用 live 支付模式")
|
||||
return
|
||||
}
|
||||
if mode == "live" && !a.paymentGatewayConfigured(r.Context()) {
|
||||
fail(w, 400, 20001, "统一支付网关配置不完整,请检查下单/退款/回调 HTTPS 地址、令牌和至少 32 位回调密钥")
|
||||
return
|
||||
}
|
||||
channels := a.availablePaymentChannels(r.Context())
|
||||
if len(channels) == 0 {
|
||||
fail(w, 400, 20001, "至少启用一个支付渠道")
|
||||
return
|
||||
}
|
||||
if mode == "live" {
|
||||
for _, channel := range channels {
|
||||
if channel["configured"] != true {
|
||||
fail(w, 400, 20001, fmt.Sprintf("%s 的生产参数不完整", channel["name"]))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
reply(w, map[string]any{"success": true, "message": "支付配置校验通过", "mode": mode, "channels": channels})
|
||||
case "storage":
|
||||
provider := a.configPlain(r.Context(), "storage.provider", "local")
|
||||
if err := a.validateStorageProviderConfig(r.Context()); err != nil {
|
||||
fail(w, 400, 20001, err.Error())
|
||||
return
|
||||
}
|
||||
reply(w, map[string]any{"success": true, "message": storageProviderName(provider) + "配置校验通过", "provider": provider})
|
||||
case "oauth":
|
||||
adminProviders, err := a.enabledAdminOAuthProviders(r.Context())
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, err.Error())
|
||||
return
|
||||
}
|
||||
userProviders, err := a.enabledUserOAuthProviders(r.Context())
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, err.Error())
|
||||
return
|
||||
}
|
||||
if _, err = a.userOAuthFrontendURL(r.Context()); err != nil {
|
||||
fail(w, http.StatusBadRequest, 20001, err.Error())
|
||||
return
|
||||
}
|
||||
if len(adminProviders) == 0 && len(userProviders) == 0 {
|
||||
reply(w, map[string]any{"success": true, "message": "当前未启用第三方登录,登录页不会显示第三方入口", "providers": []any{}})
|
||||
return
|
||||
}
|
||||
items := make([]map[string]string, 0, len(adminProviders)+len(userProviders))
|
||||
for _, provider := range adminProviders {
|
||||
items = append(items, map[string]string{"audience": "admin", "code": provider.Code, "name": provider.Name})
|
||||
}
|
||||
for _, provider := range userProviders {
|
||||
items = append(items, map[string]string{"audience": "user", "code": provider.Code, "name": provider.Name})
|
||||
}
|
||||
reply(w, map[string]any{"success": true, "message": "第三方登录配置校验通过", "providers": items})
|
||||
default:
|
||||
fail(w, http.StatusNotFound, 30001, "配置分组不存在")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) dispatchSMS(ctx context.Context, phone, scene, code string) error {
|
||||
provider := a.configPlain(ctx, "sms.provider", "debug")
|
||||
if err := a.validateSMSProviderConfig(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
switch provider {
|
||||
case "debug":
|
||||
return nil
|
||||
case "aliyun":
|
||||
_, err := a.sendAliyunSMS(ctx, phone, scene, code)
|
||||
return err
|
||||
case "tencent":
|
||||
_, err := a.sendTencentSMS(ctx, phone, scene, code)
|
||||
return err
|
||||
case "huawei":
|
||||
_, err := a.sendHuaweiSMS(ctx, phone, scene, code)
|
||||
return err
|
||||
case "webhook":
|
||||
return a.sendWebhookSMS(ctx, phone, scene, code)
|
||||
default:
|
||||
return fmt.Errorf("不支持的短信提供商")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) sendWebhookSMS(ctx context.Context, phone, scene, code string) error {
|
||||
endpoint := a.configPlain(ctx, "sms.webhook_url", "")
|
||||
if endpoint == "" {
|
||||
return fmt.Errorf("短信 Webhook 未配置")
|
||||
}
|
||||
templateID, err := a.smsTemplateID(ctx, "webhook", scene)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"phone": phone, "scene": scene, "code": code, "signName": a.configPlain(ctx, "sms.sign_name", "星遇社交"), "templateId": templateID})
|
||||
request, _ := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if token := a.configPlain(ctx, "sms.webhook_token", ""); token != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("短信网关连接失败: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("短信网关返回 HTTP %d", response.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type paymentGatewayOrder struct {
|
||||
OrderNo string
|
||||
AmountCent int
|
||||
Channel string
|
||||
Subject string
|
||||
UserID int64
|
||||
}
|
||||
|
||||
type paymentGatewayResult struct {
|
||||
ProviderOrderNo string `json:"providerOrderNo"`
|
||||
CheckoutURL string `json:"checkoutUrl"`
|
||||
AppPayload map[string]any `json:"appPayload"`
|
||||
}
|
||||
|
||||
type paymentNotifyRequest struct {
|
||||
EventID string `json:"eventId"`
|
||||
OrderNo string `json:"orderNo"`
|
||||
Channel string `json:"channel"`
|
||||
ProviderOrderNo string `json:"providerOrderNo"`
|
||||
Status string `json:"status"`
|
||||
AmountCent int `json:"amountCent"`
|
||||
}
|
||||
|
||||
func validHTTPSURL(raw string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
return err == nil && parsed.Scheme == "https" && parsed.Host != ""
|
||||
}
|
||||
|
||||
func (a *App) paymentGatewayConfigured(ctx context.Context) bool {
|
||||
createURL := a.configPlain(ctx, "payment.gateway.create_url", "")
|
||||
refundURL := a.configPlain(ctx, "payment.gateway.refund_url", "")
|
||||
notifyURL := a.configPlain(ctx, "payment.gateway.notify_url", "")
|
||||
secret := a.configPlain(ctx, "payment.gateway.notify_secret", "")
|
||||
token := a.configPlain(ctx, "payment.gateway.token", "")
|
||||
if createURL == "" || refundURL == "" || notifyURL == "" || len(secret) < 32 || token == "" {
|
||||
return false
|
||||
}
|
||||
if a.config.Environment == "production" && (!validHTTPSURL(createURL) || !validHTTPSURL(refundURL) || !validHTTPSURL(notifyURL)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) createGatewayPayment(ctx context.Context, order paymentGatewayOrder) (paymentGatewayResult, error) {
|
||||
if !a.paymentGatewayConfigured(ctx) {
|
||||
return paymentGatewayResult{}, fmt.Errorf("payment gateway is not completely configured")
|
||||
}
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"orderNo": order.OrderNo, "amountCent": order.AmountCent, "currency": "CNY",
|
||||
"channel": order.Channel, "subject": order.Subject, "userId": order.UserID,
|
||||
"notifyUrl": a.configPlain(ctx, "payment.gateway.notify_url", ""),
|
||||
"returnUrl": a.configPlain(ctx, "payment.gateway.return_url", ""),
|
||||
})
|
||||
if err != nil {
|
||||
return paymentGatewayResult{}, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.configPlain(ctx, "payment.gateway.create_url", ""), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return paymentGatewayResult{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+a.configPlain(ctx, "payment.gateway.token", ""))
|
||||
request.Header.Set("Idempotency-Key", order.OrderNo)
|
||||
|
||||
timeout, _ := strconv.Atoi(a.configPlain(ctx, "payment.gateway.timeout_seconds", "10"))
|
||||
if timeout < 3 || timeout > 30 {
|
||||
timeout = 10
|
||||
}
|
||||
response, err := (&http.Client{Timeout: time.Duration(timeout) * time.Second}).Do(request)
|
||||
if err != nil {
|
||||
return paymentGatewayResult{}, fmt.Errorf("payment gateway request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 512<<10))
|
||||
if err != nil {
|
||||
return paymentGatewayResult{}, fmt.Errorf("read payment gateway response: %w", err)
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return paymentGatewayResult{}, fmt.Errorf("payment gateway returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
var result paymentGatewayResult
|
||||
if err = json.Unmarshal(body, &envelope); err == nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
|
||||
if envelope.Code != 0 {
|
||||
return paymentGatewayResult{}, fmt.Errorf("payment gateway rejected request: %s", envelope.Message)
|
||||
}
|
||||
err = json.Unmarshal(envelope.Data, &result)
|
||||
} else {
|
||||
err = json.Unmarshal(body, &result)
|
||||
}
|
||||
if err != nil {
|
||||
return paymentGatewayResult{}, fmt.Errorf("invalid payment gateway response: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(result.ProviderOrderNo) == "" || (result.CheckoutURL == "" && len(result.AppPayload) == 0) {
|
||||
return paymentGatewayResult{}, fmt.Errorf("payment gateway response is incomplete")
|
||||
}
|
||||
if result.CheckoutURL != "" && a.config.Environment == "production" && !validHTTPSURL(result.CheckoutURL) {
|
||||
return paymentGatewayResult{}, fmt.Errorf("payment gateway returned a non-HTTPS checkout URL")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) paymentNotify(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.paymentGatewayConfigured(r.Context()) {
|
||||
fail(w, http.StatusServiceUnavailable, 50003, "支付网关未配置")
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 128<<10))
|
||||
if err != nil || len(body) == 0 {
|
||||
fail(w, http.StatusBadRequest, 20001, "支付通知内容无效")
|
||||
return
|
||||
}
|
||||
timestamp := strings.TrimSpace(r.Header.Get("X-Xingyu-Timestamp"))
|
||||
signature := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("X-Xingyu-Signature"), "sha256="))
|
||||
unixSeconds, parseErr := strconv.ParseInt(timestamp, 10, 64)
|
||||
if parseErr != nil || time.Since(time.Unix(unixSeconds, 0)) > 5*time.Minute || time.Until(time.Unix(unixSeconds, 0)) > 5*time.Minute {
|
||||
fail(w, http.StatusUnauthorized, 10006, "支付通知时间戳无效")
|
||||
return
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(a.configPlain(r.Context(), "payment.gateway.notify_secret", "")))
|
||||
_, _ = mac.Write([]byte(timestamp + "."))
|
||||
_, _ = mac.Write(body)
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
if len(signature) != len(expected) || subtle.ConstantTimeCompare([]byte(strings.ToLower(signature)), []byte(expected)) != 1 {
|
||||
fail(w, http.StatusUnauthorized, 10006, "支付通知签名无效")
|
||||
return
|
||||
}
|
||||
var notice paymentNotifyRequest
|
||||
if json.Unmarshal(body, ¬ice) != nil || notice.EventID == "" || notice.OrderNo == "" || notice.Channel == "" || notice.ProviderOrderNo == "" || notice.AmountCent <= 0 {
|
||||
fail(w, http.StatusBadRequest, 20001, "支付通知字段不完整")
|
||||
return
|
||||
}
|
||||
notice.Status = strings.ToUpper(strings.TrimSpace(notice.Status))
|
||||
if notice.Status != "PAID" && notice.Status != "FAILED" && notice.Status != "CLOSED" && notice.Status != "REFUNDED" && notice.Status != "REFUND_FAILED" {
|
||||
fail(w, http.StatusBadRequest, 20001, "支付通知状态无效")
|
||||
return
|
||||
}
|
||||
if err = a.settlePayment(r.Context(), notice, string(body)); err != nil {
|
||||
fail(w, http.StatusConflict, 20001, err.Error())
|
||||
return
|
||||
}
|
||||
reply(w, map[string]any{"success": true, "eventId": notice.EventID})
|
||||
}
|
||||
|
||||
func (a *App) settlePayment(ctx context.Context, notice paymentNotifyRequest, raw string) error {
|
||||
tx, err := a.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
result, err := tx.ExecContext(ctx, `INSERT IGNORE INTO payment_events(event_id,order_no,channel,provider_order_no,event_status,amount_cent,raw_payload) VALUES(?,?,?,?,?,?,?)`, notice.EventID, notice.OrderNo, notice.Channel, notice.ProviderOrderNo, notice.Status, notice.AmountCent, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
var orderID, userID, planID int64
|
||||
var amountCent int
|
||||
var status, channel string
|
||||
if err = tx.QueryRowContext(ctx, `SELECT id,user_id,product_id,amount_cent,status,channel FROM orders WHERE order_no=? AND deleted_at IS NULL FOR UPDATE`, notice.OrderNo).Scan(&orderID, &userID, &planID, &amountCent, &status, &channel); err != nil {
|
||||
return fmt.Errorf("order does not exist")
|
||||
}
|
||||
if channel != notice.Channel || amountCent != notice.AmountCent {
|
||||
return fmt.Errorf("payment amount or channel does not match the order")
|
||||
}
|
||||
if notice.Status == "REFUNDED" {
|
||||
if status == "REFUNDED" {
|
||||
return tx.Commit()
|
||||
}
|
||||
if status != "PAID" && status != "REFUNDING" {
|
||||
return fmt.Errorf("order status does not allow refund")
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE orders SET status='REFUNDED',payment_notified_at=NOW(3) WHERE id=?`, orderID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1 AND source IN (?,?)`, userID, fmt.Sprintf("order:%d", orderID), fmt.Sprintf("admin_order:%d", orderID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = a.recomputeMembershipTx(ctx, tx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
if notice.Status == "REFUND_FAILED" {
|
||||
if status == "REFUNDING" {
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE orders SET status='PAID',payment_notified_at=NOW(3) WHERE id=?`, orderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
if notice.Status == "PAID" && (status == "PAID" || status == "REFUNDING" || status == "REFUNDED") {
|
||||
return tx.Commit()
|
||||
}
|
||||
if status != "CREATED" {
|
||||
return fmt.Errorf("order status does not allow payment")
|
||||
}
|
||||
if notice.Status != "PAID" {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
var durationDays, level int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT duration_days,level FROM membership_plans WHERE id=? AND deleted_at IS NULL`, planID).Scan(&durationDays, &level); err != nil {
|
||||
return fmt.Errorf("membership plan does not exist")
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE orders SET status='PAID',paid_at=NOW(3),paid_amount_cent=?,provider_order_no=?,payment_notified_at=NOW(3) WHERE id=?`, notice.AmountCent, notice.ProviderOrderNo, orderID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = grantOrderMembershipTx(ctx, tx, orderID, userID, planID, durationDays, level); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (a *App) createGatewayRefund(ctx context.Context, orderNo, providerOrderNo string, amountCent int) error {
|
||||
endpoint := a.configPlain(ctx, "payment.gateway.refund_url", "")
|
||||
if endpoint == "" || (a.config.Environment == "production" && !validHTTPSURL(endpoint)) {
|
||||
return fmt.Errorf("payment refund gateway is not configured")
|
||||
}
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"orderNo": orderNo, "providerOrderNo": providerOrderNo, "amountCent": amountCent,
|
||||
"currency": "CNY", "reason": "admin_requested",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+a.configPlain(ctx, "payment.gateway.token", ""))
|
||||
request.Header.Set("Idempotency-Key", "refund:"+orderNo)
|
||||
timeout, _ := strconv.Atoi(a.configPlain(ctx, "payment.gateway.timeout_seconds", "10"))
|
||||
if timeout < 3 || timeout > 30 {
|
||||
timeout = 10
|
||||
}
|
||||
response, err := (&http.Client{Timeout: time.Duration(timeout) * time.Second}).Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("refund gateway request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 256<<10))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("refund gateway returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
var gatewayResponse struct {
|
||||
Code *int `json:"code"`
|
||||
Success *bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if len(bytes.TrimSpace(body)) > 0 {
|
||||
if err = json.Unmarshal(body, &gatewayResponse); err != nil {
|
||||
return fmt.Errorf("refund gateway returned invalid JSON: %w", err)
|
||||
}
|
||||
if (gatewayResponse.Code != nil && *gatewayResponse.Code != 0) || (gatewayResponse.Success != nil && !*gatewayResponse.Success) {
|
||||
return fmt.Errorf("refund gateway rejected request: %s", gatewayResponse.Message)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) requestLiveRefund(w http.ResponseWriter, r *http.Request, orderID int64) {
|
||||
tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "创建退款申请失败")
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
var userID int64
|
||||
var amountCent int
|
||||
var orderNo, providerOrderNo, status string
|
||||
if err = tx.QueryRowContext(r.Context(), `SELECT user_id,amount_cent,order_no,provider_order_no,status FROM orders WHERE id=? AND deleted_at IS NULL FOR UPDATE`, orderID).Scan(&userID, &amountCent, &orderNo, &providerOrderNo, &status); err != nil {
|
||||
fail(w, http.StatusNotFound, 30001, "订单不存在")
|
||||
return
|
||||
}
|
||||
if status != "PAID" && status != "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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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[:] }
|
||||
@@ -0,0 +1,416 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const smsResponseLimit = 256 << 10
|
||||
|
||||
type smsDeliveryResult struct {
|
||||
MessageID string
|
||||
}
|
||||
|
||||
func parseSMSProviderEndpoint(provider, raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, fmt.Errorf("%s API 地址必须是无账号、查询参数和片段的有效 HTTPS 地址", cloudSMSProviderName(provider))
|
||||
}
|
||||
path := strings.TrimRight(parsed.EscapedPath(), "/")
|
||||
switch provider {
|
||||
case "aliyun", "tencent":
|
||||
if path != "" {
|
||||
return nil, fmt.Errorf("%s API 地址不能包含路径", cloudSMSProviderName(provider))
|
||||
}
|
||||
case "huawei":
|
||||
if !strings.HasSuffix(path, "/sms/batchSendSms/v1") {
|
||||
return nil, fmt.Errorf("华为云 APP 接入地址必须包含 /sms/batchSendSms/v1")
|
||||
}
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func (a *App) validateSMSProviderConfig(ctx context.Context) error {
|
||||
provider := a.configPlain(ctx, "sms.provider", "debug")
|
||||
if !containsString([]string{"aliyun", "tencent", "huawei", "webhook", "debug"}, provider) {
|
||||
return fmt.Errorf("不支持的短信厂商 %q", provider)
|
||||
}
|
||||
if a.config.Environment == "production" && provider == "debug" {
|
||||
return fmt.Errorf("生产环境禁止使用 debug 短信提供商")
|
||||
}
|
||||
for _, spec := range integrationSpecs["sms"] {
|
||||
if !spec.Required || (len(spec.Providers) > 0 && !containsString(spec.Providers, provider)) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(a.configPlain(ctx, spec.Key, "")) == "" {
|
||||
return fmt.Errorf("请填写%s", spec.Label)
|
||||
}
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "aliyun", "tencent", "huawei":
|
||||
endpoint := a.configPlain(ctx, "sms."+provider+".endpoint", "")
|
||||
if _, err := parseSMSProviderEndpoint(provider, endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.smsTemplateParams(ctx, provider, "000000"); err != nil {
|
||||
return err
|
||||
}
|
||||
case "webhook":
|
||||
endpoint := a.configPlain(ctx, "sms.webhook_url", "")
|
||||
if a.config.Environment == "production" && !validHTTPSURL(endpoint) {
|
||||
return fmt.Errorf("生产环境 Webhook 地址必须使用 HTTPS")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloudSMSProviderName(provider string) string {
|
||||
switch provider {
|
||||
case "aliyun":
|
||||
return "阿里云"
|
||||
case "tencent":
|
||||
return "腾讯云"
|
||||
case "huawei":
|
||||
return "华为云"
|
||||
case "webhook":
|
||||
return "Webhook"
|
||||
case "debug":
|
||||
return "本地调试"
|
||||
default:
|
||||
return provider
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) smsTemplateID(ctx context.Context, provider, scene string) (string, error) {
|
||||
if !containsString([]string{"register", "login", "reset"}, scene) {
|
||||
return "", fmt.Errorf("短信验证码场景无效")
|
||||
}
|
||||
key := "sms." + provider + ".template_" + scene
|
||||
if provider == "webhook" {
|
||||
key = "sms.template_" + scene
|
||||
}
|
||||
value := strings.TrimSpace(a.configPlain(ctx, key, ""))
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("%s%s模板未配置", cloudSMSProviderName(provider), scene)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (a *App) smsTemplateParams(ctx context.Context, provider, code string) (string, error) {
|
||||
raw := a.configPlain(ctx, "sms."+provider+".template_params", "")
|
||||
expires, _ := strconv.Atoi(a.configPlain(ctx, "sms.expire_seconds", "300"))
|
||||
return renderSMSTemplateParams(provider, raw, code, expires)
|
||||
}
|
||||
|
||||
func renderSMSTemplateParams(provider, raw, code string, expires int) (string, error) {
|
||||
if expires < 60 || expires > 1800 {
|
||||
expires = 300
|
||||
}
|
||||
minutes := (expires + 59) / 60
|
||||
rendered := strings.ReplaceAll(raw, "{{code}}", code)
|
||||
rendered = strings.ReplaceAll(rendered, "{{minutes}}", strconv.Itoa(minutes))
|
||||
if provider == "aliyun" {
|
||||
var object map[string]any
|
||||
if json.Unmarshal([]byte(rendered), &object) != nil || len(object) == 0 {
|
||||
return "", fmt.Errorf("阿里云模板变量必须是有效的非空 JSON 对象")
|
||||
}
|
||||
payload, _ := json.Marshal(object)
|
||||
return string(payload), nil
|
||||
}
|
||||
var values []string
|
||||
if json.Unmarshal([]byte(rendered), &values) != nil || len(values) == 0 {
|
||||
return "", fmt.Errorf("%s模板参数必须是有效的非空 JSON 字符串数组", cloudSMSProviderName(provider))
|
||||
}
|
||||
payload, _ := json.Marshal(values)
|
||||
return string(payload), nil
|
||||
}
|
||||
|
||||
func huaweiWSSE(appKey, appSecret, nonce, created string) (string, string) {
|
||||
digestHash := sha256.Sum256([]byte(nonce + created + appSecret))
|
||||
passwordDigest := base64.StdEncoding.EncodeToString(digestHash[:])
|
||||
authorization := `WSSE realm="SDP",profile="UsernameToken",type="Appkey"`
|
||||
wsse := `UsernameToken Username="` + appKey + `",PasswordDigest="` + passwordDigest + `",Nonce="` + nonce + `",Created="` + created + `"`
|
||||
return authorization, wsse
|
||||
}
|
||||
|
||||
func smsHTTPClient() *http.Client {
|
||||
return &http.Client{Timeout: 8 * time.Second}
|
||||
}
|
||||
|
||||
func readSMSResponse(response *http.Response) ([]byte, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, smsResponseLimit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("短信厂商返回 HTTP %d", response.StatusCode)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func providerError(provider, code, message string) error {
|
||||
message = strings.TrimSpace(message)
|
||||
if len(message) > 300 {
|
||||
message = message[:300]
|
||||
}
|
||||
if message == "" {
|
||||
message = "请求失败"
|
||||
}
|
||||
return fmt.Errorf("%s短信发送失败 [%s]: %s", cloudSMSProviderName(provider), code, message)
|
||||
}
|
||||
|
||||
func (a *App) sendAliyunSMS(ctx context.Context, phone, scene, code string) (smsDeliveryResult, error) {
|
||||
endpoint := a.configPlain(ctx, "sms.aliyun.endpoint", "https://dysmsapi.aliyuncs.com")
|
||||
parsed, err := parseSMSProviderEndpoint("aliyun", endpoint)
|
||||
if err != nil {
|
||||
return smsDeliveryResult{}, err
|
||||
}
|
||||
templateID, err := a.smsTemplateID(ctx, "aliyun", scene)
|
||||
if err != nil {
|
||||
return smsDeliveryResult{}, err
|
||||
}
|
||||
templateParams, err := a.smsTemplateParams(ctx, "aliyun", code)
|
||||
if err != nil {
|
||||
return smsDeliveryResult{}, err
|
||||
}
|
||||
query := url.Values{
|
||||
"PhoneNumbers": {phone},
|
||||
"SignName": {a.configPlain(ctx, "sms.aliyun.sign_name", "")},
|
||||
"TemplateCode": {templateID},
|
||||
"TemplateParam": {templateParams},
|
||||
}
|
||||
canonicalQuery := strings.ReplaceAll(query.Encode(), "+", "%20")
|
||||
parsed.RawQuery = canonicalQuery
|
||||
canonicalURI := "/"
|
||||
now := time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
nonce := randomToken()[:32]
|
||||
emptyHash := sha256.Sum256(nil)
|
||||
payloadHash := hex.EncodeToString(emptyHash[:])
|
||||
canonicalHeaders := "host:" + parsed.Host + "\n" +
|
||||
"x-acs-action:SendSms\n" +
|
||||
"x-acs-content-sha256:" + payloadHash + "\n" +
|
||||
"x-acs-date:" + now + "\n" +
|
||||
"x-acs-signature-nonce:" + nonce + "\n" +
|
||||
"x-acs-version:2017-05-25\n"
|
||||
signedHeaders := "host;x-acs-action;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version"
|
||||
canonicalRequest := "POST\n" + canonicalURI + "\n" + canonicalQuery + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + payloadHash
|
||||
requestHash := sha256.Sum256([]byte(canonicalRequest))
|
||||
stringToSign := "ACS3-HMAC-SHA256\n" + hex.EncodeToString(requestHash[:])
|
||||
accessKeyID := a.configPlain(ctx, "sms.aliyun.access_key_id", "")
|
||||
mac := hmac.New(sha256.New, []byte(a.configPlain(ctx, "sms.aliyun.access_key_secret", "")))
|
||||
_, _ = mac.Write([]byte(stringToSign))
|
||||
authorization := "ACS3-HMAC-SHA256 Credential=" + accessKeyID + ",SignedHeaders=" + signedHeaders + ",Signature=" + hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return smsDeliveryResult{}, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Authorization", authorization)
|
||||
request.Header.Set("x-acs-action", "SendSms")
|
||||
request.Header.Set("x-acs-content-sha256", payloadHash)
|
||||
request.Header.Set("x-acs-date", now)
|
||||
request.Header.Set("x-acs-signature-nonce", nonce)
|
||||
request.Header.Set("x-acs-version", "2017-05-25")
|
||||
response, err := smsHTTPClient().Do(request)
|
||||
if err != nil {
|
||||
return smsDeliveryResult{}, fmt.Errorf("阿里云短信连接失败: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := readSMSResponse(response)
|
||||
if err != nil {
|
||||
return smsDeliveryResult{}, err
|
||||
}
|
||||
var result struct {
|
||||
BizID string `json:"BizId"`
|
||||
Code string `json:"Code"`
|
||||
Message string `json:"Message"`
|
||||
RequestID string `json:"RequestId"`
|
||||
}
|
||||
if json.Unmarshal(body, &result) != nil {
|
||||
return smsDeliveryResult{}, fmt.Errorf("阿里云短信返回无效 JSON")
|
||||
}
|
||||
if result.Code != "OK" {
|
||||
return smsDeliveryResult{}, providerError("aliyun", result.Code, result.Message)
|
||||
}
|
||||
return smsDeliveryResult{MessageID: result.BizID}, nil
|
||||
}
|
||||
|
||||
func hmacSHA256(key, value []byte) []byte {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
_, _ = mac.Write(value)
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func (a *App) sendTencentSMS(ctx context.Context, phone, scene, code string) (smsDeliveryResult, error) {
|
||||
endpoint := a.configPlain(ctx, "sms.tencent.endpoint", "https://sms.tencentcloudapi.com")
|
||||
parsed, err := parseSMSProviderEndpoint("tencent", endpoint)
|
||||
if err != nil {
|
||||
return smsDeliveryResult{}, err
|
||||
}
|
||||
templateID, err := a.smsTemplateID(ctx, "tencent", scene)
|
||||
if err != nil {
|
||||
return smsDeliveryResult{}, err
|
||||
}
|
||||
paramsJSON, err := a.smsTemplateParams(ctx, "tencent", code)
|
||||
if err != nil {
|
||||
return smsDeliveryResult{}, err
|
||||
}
|
||||
var params []string
|
||||
_ = json.Unmarshal([]byte(paramsJSON), ¶ms)
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"PhoneNumberSet": []string{"+86" + phone},
|
||||
"SignName": a.configPlain(ctx, "sms.tencent.sign_name", ""),
|
||||
"SmsSdkAppId": a.configPlain(ctx, "sms.tencent.sdk_app_id", ""),
|
||||
"TemplateId": templateID,
|
||||
"TemplateParamSet": params,
|
||||
})
|
||||
timestamp := time.Now().Unix()
|
||||
date := time.Unix(timestamp, 0).UTC().Format("2006-01-02")
|
||||
contentType := "application/json; charset=utf-8"
|
||||
canonicalHeaders := "content-type:" + contentType + "\nhost:" + parsed.Host + "\n"
|
||||
signedHeaders := "content-type;host"
|
||||
payloadHash := sha256.Sum256(payload)
|
||||
canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hex.EncodeToString(payloadHash[:])
|
||||
canonicalHash := sha256.Sum256([]byte(canonicalRequest))
|
||||
credentialScope := date + "/sms/tc3_request"
|
||||
stringToSign := "TC3-HMAC-SHA256\n" + strconv.FormatInt(timestamp, 10) + "\n" + credentialScope + "\n" + hex.EncodeToString(canonicalHash[:])
|
||||
secretKey := a.configPlain(ctx, "sms.tencent.secret_key", "")
|
||||
secretDate := hmacSHA256([]byte("TC3"+secretKey), []byte(date))
|
||||
secretService := hmacSHA256(secretDate, []byte("sms"))
|
||||
secretSigning := hmacSHA256(secretService, []byte("tc3_request"))
|
||||
signature := hex.EncodeToString(hmacSHA256(secretSigning, []byte(stringToSign)))
|
||||
authorization := "TC3-HMAC-SHA256 Credential=" + a.configPlain(ctx, "sms.tencent.secret_id", "") + "/" + credentialScope + ", SignedHeaders=" + signedHeaders + ", Signature=" + signature
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return smsDeliveryResult{}, err
|
||||
}
|
||||
request.Header.Set("Authorization", authorization)
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
request.Header.Set("X-TC-Action", "SendSms")
|
||||
request.Header.Set("X-TC-Version", "2021-01-11")
|
||||
request.Header.Set("X-TC-Timestamp", strconv.FormatInt(timestamp, 10))
|
||||
request.Header.Set("X-TC-Region", a.configPlain(ctx, "sms.tencent.region", "ap-guangzhou"))
|
||||
response, err := smsHTTPClient().Do(request)
|
||||
if err != nil {
|
||||
return smsDeliveryResult{}, fmt.Errorf("腾讯云短信连接失败: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := readSMSResponse(response)
|
||||
if err != nil {
|
||||
return smsDeliveryResult{}, err
|
||||
}
|
||||
var result struct {
|
||||
Response struct {
|
||||
Error *struct {
|
||||
Code string `json:"Code"`
|
||||
Message string `json:"Message"`
|
||||
} `json:"Error"`
|
||||
RequestID string `json:"RequestId"`
|
||||
SendStatusSet []struct {
|
||||
Code string `json:"Code"`
|
||||
Message string `json:"Message"`
|
||||
SerialNo string `json:"SerialNo"`
|
||||
} `json:"SendStatusSet"`
|
||||
} `json:"Response"`
|
||||
}
|
||||
if json.Unmarshal(body, &result) != nil {
|
||||
return smsDeliveryResult{}, fmt.Errorf("腾讯云短信返回无效 JSON")
|
||||
}
|
||||
if result.Response.Error != nil {
|
||||
return smsDeliveryResult{}, providerError("tencent", result.Response.Error.Code, result.Response.Error.Message)
|
||||
}
|
||||
if len(result.Response.SendStatusSet) == 0 {
|
||||
return smsDeliveryResult{}, fmt.Errorf("腾讯云短信未返回发送状态")
|
||||
}
|
||||
status := result.Response.SendStatusSet[0]
|
||||
if !strings.EqualFold(status.Code, "Ok") {
|
||||
return smsDeliveryResult{}, providerError("tencent", status.Code, status.Message)
|
||||
}
|
||||
return smsDeliveryResult{MessageID: status.SerialNo}, nil
|
||||
}
|
||||
|
||||
func (a *App) sendHuaweiSMS(ctx context.Context, phone, scene, code string) (smsDeliveryResult, error) {
|
||||
endpoint := a.configPlain(ctx, "sms.huawei.endpoint", "")
|
||||
parsed, err := parseSMSProviderEndpoint("huawei", endpoint)
|
||||
if err != nil {
|
||||
return smsDeliveryResult{}, err
|
||||
}
|
||||
templateID, err := a.smsTemplateID(ctx, "huawei", scene)
|
||||
if err != nil {
|
||||
return smsDeliveryResult{}, err
|
||||
}
|
||||
paramsJSON, err := a.smsTemplateParams(ctx, "huawei", code)
|
||||
if err != nil {
|
||||
return smsDeliveryResult{}, err
|
||||
}
|
||||
appKey := a.configPlain(ctx, "sms.huawei.app_key", "")
|
||||
nonce := randomToken()[:32]
|
||||
created := time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
authorization, wsse := huaweiWSSE(appKey, a.configPlain(ctx, "sms.huawei.app_secret", ""), nonce, created)
|
||||
form := url.Values{
|
||||
"from": {a.configPlain(ctx, "sms.huawei.sender", "")},
|
||||
"to": {"+86" + phone},
|
||||
"templateId": {templateID},
|
||||
"templateParas": {paramsJSON},
|
||||
}
|
||||
if signature := strings.TrimSpace(a.configPlain(ctx, "sms.huawei.signature", "")); signature != "" {
|
||||
form.Set("signature", signature)
|
||||
}
|
||||
if callback := strings.TrimSpace(a.configPlain(ctx, "sms.huawei.status_callback", "")); callback != "" {
|
||||
form.Set("statusCallback", callback)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return smsDeliveryResult{}, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Authorization", authorization)
|
||||
request.Header.Set("X-WSSE", wsse)
|
||||
response, err := smsHTTPClient().Do(request)
|
||||
if err != nil {
|
||||
return smsDeliveryResult{}, fmt.Errorf("华为云短信连接失败: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := readSMSResponse(response)
|
||||
if err != nil {
|
||||
return smsDeliveryResult{}, err
|
||||
}
|
||||
var result struct {
|
||||
Code string `json:"code"`
|
||||
Description string `json:"description"`
|
||||
Result []struct {
|
||||
MessageID string `json:"smsMsgId"`
|
||||
Status string `json:"status"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if json.Unmarshal(body, &result) != nil {
|
||||
return smsDeliveryResult{}, fmt.Errorf("华为云短信返回无效 JSON")
|
||||
}
|
||||
if result.Code != "000000" {
|
||||
return smsDeliveryResult{}, providerError("huawei", result.Code, result.Description)
|
||||
}
|
||||
if len(result.Result) == 0 || result.Result[0].Status != "000000" {
|
||||
status := "EMPTY_RESULT"
|
||||
if len(result.Result) > 0 {
|
||||
status = result.Result[0].Status
|
||||
}
|
||||
return smsDeliveryResult{}, providerError("huawei", status, "短信未被平台接受")
|
||||
}
|
||||
return smsDeliveryResult{MessageID: result.Result[0].MessageID}, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user