Add backend foundation and config-driven workbench

This commit is contained in:
2026-04-01 15:01:44 +08:00
parent 88b8f05f03
commit 94a1f0ba78
68 changed files with 10833 additions and 0 deletions

View File

@@ -0,0 +1,595 @@
package service
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"cmr-backend/internal/apperr"
"cmr-backend/internal/platform/jwtx"
"cmr-backend/internal/platform/security"
"cmr-backend/internal/platform/wechatmini"
"cmr-backend/internal/store/postgres"
)
type AuthSettings struct {
AppEnv string
RefreshTTL time.Duration
SMSCodeTTL time.Duration
SMSCodeCooldown time.Duration
SMSProvider string
DevSMSCode string
WechatMini *wechatmini.Client
}
type AuthService struct {
cfg AuthSettings
store *postgres.Store
jwtManager *jwtx.Manager
}
type SendSMSCodeInput struct {
CountryCode string `json:"countryCode"`
Mobile string `json:"mobile"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
Scene string `json:"scene"`
}
type SendSMSCodeResult struct {
TTLSeconds int64 `json:"ttlSeconds"`
CooldownSeconds int64 `json:"cooldownSeconds"`
DevCode *string `json:"devCode,omitempty"`
}
type LoginSMSInput struct {
CountryCode string `json:"countryCode"`
Mobile string `json:"mobile"`
Code string `json:"code"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
type LoginWechatMiniInput struct {
Code string `json:"code"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
type BindMobileInput struct {
UserID string `json:"-"`
CountryCode string `json:"countryCode"`
Mobile string `json:"mobile"`
Code string `json:"code"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
type RefreshTokenInput struct {
RefreshToken string `json:"refreshToken"`
ClientType string `json:"clientType"`
DeviceKey string `json:"deviceKey"`
}
type LogoutInput struct {
RefreshToken string `json:"refreshToken"`
UserID string `json:"-"`
}
type AuthUser struct {
ID string `json:"id"`
PublicID string `json:"publicId"`
Status string `json:"status"`
Nickname *string `json:"nickname,omitempty"`
AvatarURL *string `json:"avatarUrl,omitempty"`
}
type AuthTokens struct {
AccessToken string `json:"accessToken"`
AccessTokenExpiresAt string `json:"accessTokenExpiresAt"`
RefreshToken string `json:"refreshToken"`
RefreshTokenExpiresAt string `json:"refreshTokenExpiresAt"`
}
type AuthResult struct {
User AuthUser `json:"user"`
Tokens AuthTokens `json:"tokens"`
NewUser bool `json:"newUser"`
}
func NewAuthService(cfg AuthSettings, store *postgres.Store, jwtManager *jwtx.Manager) *AuthService {
return &AuthService{
cfg: cfg,
store: store,
jwtManager: jwtManager,
}
}
func (s *AuthService) SendSMSCode(ctx context.Context, input SendSMSCodeInput) (*SendSMSCodeResult, error) {
input.CountryCode = normalizeCountryCode(input.CountryCode)
input.Mobile = normalizeMobile(input.Mobile)
input.Scene = normalizeScene(input.Scene)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.Mobile == "" || input.DeviceKey == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile and deviceKey are required")
}
latest, err := s.store.GetLatestSMSCodeMeta(ctx, input.CountryCode, input.Mobile, input.ClientType, input.Scene)
if err != nil {
return nil, err
}
now := time.Now().UTC()
if latest != nil && latest.CooldownUntil.After(now) {
return nil, apperr.New(http.StatusTooManyRequests, "sms_cooldown", "sms code sent too frequently")
}
code := s.cfg.DevSMSCode
if code == "" {
code, err = security.GenerateNumericCode(6)
if err != nil {
return nil, err
}
}
expiresAt := now.Add(s.cfg.SMSCodeTTL)
cooldownUntil := now.Add(s.cfg.SMSCodeCooldown)
if err := s.store.CreateSMSCode(ctx, postgres.CreateSMSCodeParams{
Scene: input.Scene,
CountryCode: input.CountryCode,
Mobile: input.Mobile,
ClientType: input.ClientType,
DeviceKey: input.DeviceKey,
CodeHash: security.HashText(code),
ProviderName: s.cfg.SMSProvider,
ProviderDebug: map[string]any{"mode": s.cfg.SMSProvider},
ExpiresAt: expiresAt,
CooldownUntil: cooldownUntil,
}); err != nil {
return nil, err
}
result := &SendSMSCodeResult{
TTLSeconds: int64(s.cfg.SMSCodeTTL.Seconds()),
CooldownSeconds: int64(s.cfg.SMSCodeCooldown.Seconds()),
}
if strings.EqualFold(s.cfg.SMSProvider, "console") || strings.EqualFold(s.cfg.AppEnv, "development") {
result.DevCode = &code
}
return result, nil
}
func (s *AuthService) LoginSMS(ctx context.Context, input LoginSMSInput) (*AuthResult, error) {
input.CountryCode = normalizeCountryCode(input.CountryCode)
input.Mobile = normalizeMobile(input.Mobile)
input.Code = strings.TrimSpace(input.Code)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.Mobile == "" || input.DeviceKey == "" || input.Code == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "mobile, code and deviceKey are required")
}
codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, input.ClientType, "login")
if err != nil {
return nil, err
}
if codeRecord == nil || codeRecord.CodeHash != security.HashText(input.Code) {
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "invalid sms code")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
consumed, err := s.store.ConsumeSMSCode(ctx, tx, codeRecord.ID)
if err != nil {
return nil, err
}
if !consumed {
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "sms code already used")
}
user, err := s.store.FindUserByMobile(ctx, tx, input.CountryCode, input.Mobile)
if err != nil {
return nil, err
}
newUser := false
if user == nil {
userPublicID, err := security.GeneratePublicID("usr")
if err != nil {
return nil, err
}
user, err = s.store.CreateUser(ctx, tx, postgres.CreateUserParams{
PublicID: userPublicID,
Status: "active",
})
if err != nil {
return nil, err
}
if err := s.store.CreateMobileIdentity(ctx, tx, postgres.CreateMobileIdentityParams{
UserID: user.ID,
CountryCode: input.CountryCode,
Mobile: input.Mobile,
Provider: "mobile",
ProviderSubj: input.CountryCode + ":" + input.Mobile,
IdentityType: "mobile",
}); err != nil {
return nil, err
}
newUser = true
}
if err := s.store.TouchUserLogin(ctx, tx, user.ID); err != nil {
return nil, err
}
result, err := s.issueAuthResult(ctx, tx, *user, input.ClientType, input.DeviceKey, newUser)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
func (s *AuthService) Refresh(ctx context.Context, input RefreshTokenInput) (*AuthResult, error) {
input.RefreshToken = strings.TrimSpace(input.RefreshToken)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.RefreshToken == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "refreshToken is required")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
record, err := s.store.GetRefreshTokenForUpdate(ctx, tx, security.HashText(input.RefreshToken))
if err != nil {
return nil, err
}
if record == nil || record.IsRevoked || record.ExpiresAt.Before(time.Now().UTC()) {
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token is invalid or expired")
}
if input.ClientType != "" && input.ClientType != record.ClientType {
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token client mismatch")
}
if input.DeviceKey != "" && record.DeviceKey != nil && input.DeviceKey != *record.DeviceKey {
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token device mismatch")
}
user, err := s.store.GetUserByID(ctx, tx, record.UserID)
if err != nil {
return nil, err
}
if user == nil {
return nil, apperr.New(http.StatusUnauthorized, "invalid_refresh_token", "refresh token user not found")
}
result, refreshTokenID, err := s.issueAuthResultWithRefreshID(ctx, tx, *user, record.ClientType, nullableStringValue(record.DeviceKey), false)
if err != nil {
return nil, err
}
if err := s.store.RotateRefreshToken(ctx, tx, record.ID, refreshTokenID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
func (s *AuthService) LoginWechatMini(ctx context.Context, input LoginWechatMiniInput) (*AuthResult, error) {
input.Code = strings.TrimSpace(input.Code)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.ClientType != "wechat" {
return nil, apperr.New(http.StatusBadRequest, "invalid_client_type", "wechat mini login requires clientType=wechat")
}
if input.Code == "" || strings.TrimSpace(input.DeviceKey) == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "code and deviceKey are required")
}
if s.cfg.WechatMini == nil {
return nil, apperr.New(http.StatusNotImplemented, "wechat_not_configured", "wechat mini provider is not configured")
}
session, err := s.cfg.WechatMini.ExchangeCode(ctx, input.Code)
if err != nil {
return nil, apperr.New(http.StatusUnauthorized, "wechat_login_failed", err.Error())
}
openIDSubject := session.AppID + ":" + session.OpenID
unionIDSubject := strings.TrimSpace(session.UnionID)
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
user, err := s.store.FindUserByProviderSubject(ctx, tx, "wechat_mini", openIDSubject)
if err != nil {
return nil, err
}
if user == nil && unionIDSubject != "" {
user, err = s.store.FindUserByProviderSubject(ctx, tx, "wechat_unionid", unionIDSubject)
if err != nil {
return nil, err
}
}
newUser := false
if user == nil {
userPublicID, err := security.GeneratePublicID("usr")
if err != nil {
return nil, err
}
user, err = s.store.CreateUser(ctx, tx, postgres.CreateUserParams{
PublicID: userPublicID,
Status: "active",
})
if err != nil {
return nil, err
}
newUser = true
}
profileJSON, err := json.Marshal(map[string]any{
"appId": session.AppID,
})
if err != nil {
return nil, err
}
if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{
UserID: user.ID,
IdentityType: "wechat_mini_openid",
Provider: "wechat_mini",
ProviderSubj: openIDSubject,
ProfileJSON: string(profileJSON),
}); err != nil {
return nil, err
}
if unionIDSubject != "" {
if err := s.store.CreateIdentity(ctx, tx, postgres.CreateIdentityParams{
UserID: user.ID,
IdentityType: "wechat_unionid",
Provider: "wechat_unionid",
ProviderSubj: unionIDSubject,
ProfileJSON: "{}",
}); err != nil {
return nil, err
}
}
if err := s.store.TouchUserLogin(ctx, tx, user.ID); err != nil {
return nil, err
}
result, err := s.issueAuthResult(ctx, tx, *user, input.ClientType, input.DeviceKey, newUser)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
func (s *AuthService) BindMobile(ctx context.Context, input BindMobileInput) (*AuthResult, error) {
input.CountryCode = normalizeCountryCode(input.CountryCode)
input.Mobile = normalizeMobile(input.Mobile)
input.Code = strings.TrimSpace(input.Code)
if err := validateClientType(input.ClientType); err != nil {
return nil, err
}
if input.UserID == "" || input.Mobile == "" || input.Code == "" || strings.TrimSpace(input.DeviceKey) == "" {
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "user, mobile, code and deviceKey are required")
}
codeRecord, err := s.store.GetLatestValidSMSCode(ctx, input.CountryCode, input.Mobile, input.ClientType, "bind_mobile")
if err != nil {
return nil, err
}
if codeRecord == nil || codeRecord.CodeHash != security.HashText(input.Code) {
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "invalid sms code")
}
tx, err := s.store.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
consumed, err := s.store.ConsumeSMSCode(ctx, tx, codeRecord.ID)
if err != nil {
return nil, err
}
if !consumed {
return nil, apperr.New(http.StatusUnauthorized, "invalid_sms_code", "sms code already used")
}
currentUser, err := s.store.GetUserByID(ctx, tx, input.UserID)
if err != nil {
return nil, err
}
if currentUser == nil {
return nil, apperr.New(http.StatusNotFound, "user_not_found", "current user not found")
}
mobileUser, err := s.store.FindUserByMobile(ctx, tx, input.CountryCode, input.Mobile)
if err != nil {
return nil, err
}
finalUser := currentUser
newlyBound := false
if mobileUser == nil {
if err := s.store.CreateMobileIdentity(ctx, tx, postgres.CreateMobileIdentityParams{
UserID: currentUser.ID,
CountryCode: input.CountryCode,
Mobile: input.Mobile,
Provider: "mobile",
ProviderSubj: input.CountryCode + ":" + input.Mobile,
IdentityType: "mobile",
}); err != nil {
return nil, err
}
newlyBound = true
} else if mobileUser.ID != currentUser.ID {
if err := s.store.TransferNonMobileIdentities(ctx, tx, currentUser.ID, mobileUser.ID); err != nil {
return nil, err
}
if err := s.store.RevokeRefreshTokensByUserID(ctx, tx, currentUser.ID); err != nil {
return nil, err
}
if err := s.store.DeactivateUser(ctx, tx, currentUser.ID); err != nil {
return nil, err
}
finalUser = mobileUser
}
if err := s.store.TouchUserLogin(ctx, tx, finalUser.ID); err != nil {
return nil, err
}
result, err := s.issueAuthResult(ctx, tx, *finalUser, input.ClientType, input.DeviceKey, newlyBound)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
func (s *AuthService) Logout(ctx context.Context, input LogoutInput) error {
if strings.TrimSpace(input.RefreshToken) == "" {
return nil
}
return s.store.RevokeRefreshToken(ctx, security.HashText(strings.TrimSpace(input.RefreshToken)))
}
func (s *AuthService) issueAuthResult(
ctx context.Context,
tx postgres.Tx,
user postgres.User,
clientType string,
deviceKey string,
newUser bool,
) (*AuthResult, error) {
result, _, err := s.issueAuthResultWithRefreshID(ctx, tx, user, clientType, deviceKey, newUser)
return result, err
}
func (s *AuthService) issueAuthResultWithRefreshID(
ctx context.Context,
tx postgres.Tx,
user postgres.User,
clientType string,
deviceKey string,
newUser bool,
) (*AuthResult, string, error) {
accessToken, accessExpiresAt, err := s.jwtManager.IssueAccessToken(user.ID, user.PublicID)
if err != nil {
return nil, "", err
}
refreshToken, err := security.GenerateToken(32)
if err != nil {
return nil, "", err
}
refreshTokenHash := security.HashText(refreshToken)
refreshExpiresAt := time.Now().UTC().Add(s.cfg.RefreshTTL)
refreshID, err := s.store.CreateRefreshToken(ctx, tx, postgres.CreateRefreshTokenParams{
UserID: user.ID,
ClientType: clientType,
DeviceKey: deviceKey,
TokenHash: refreshTokenHash,
ExpiresAt: refreshExpiresAt,
})
if err != nil {
return nil, "", err
}
return &AuthResult{
User: AuthUser{
ID: user.ID,
PublicID: user.PublicID,
Status: user.Status,
Nickname: user.Nickname,
AvatarURL: user.AvatarURL,
},
Tokens: AuthTokens{
AccessToken: accessToken,
AccessTokenExpiresAt: accessExpiresAt.Format(time.RFC3339),
RefreshToken: refreshToken,
RefreshTokenExpiresAt: refreshExpiresAt.Format(time.RFC3339),
},
NewUser: newUser,
}, refreshID, nil
}
func validateClientType(clientType string) error {
switch clientType {
case "app", "wechat":
return nil
default:
return apperr.New(http.StatusBadRequest, "invalid_client_type", "clientType must be app or wechat")
}
}
func normalizeCountryCode(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "86"
}
return strings.TrimPrefix(value, "+")
}
func normalizeMobile(value string) string {
value = strings.TrimSpace(value)
value = strings.ReplaceAll(value, " ", "")
value = strings.ReplaceAll(value, "-", "")
return value
}
func normalizeScene(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "login"
}
return value
}
func nullableStringValue(value *string) string {
if value == nil {
return ""
}
return *value
}