Compare commits
42 Commits
99ed42aff5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
292c37a177 | ||
|
|
2b2fd162a2 | ||
|
|
1aece314c6 | ||
|
|
b3ac9e8060 | ||
|
|
8eb9adda68 | ||
|
|
a33e6be08f | ||
|
|
919ef704af | ||
|
|
5cfbbffb77 | ||
|
|
1cbd043297 | ||
|
|
5da272af2e | ||
|
|
25e566450e | ||
|
|
32584e25b2 | ||
|
|
66c1ee9620 | ||
|
|
cc8dfdad80 | ||
|
|
65353ee4a2 | ||
|
|
2ae47c5049 | ||
|
|
c7b11dca35 | ||
|
|
25c4628b5f | ||
|
|
525f2a6846 | ||
|
|
e9457b8a24 | ||
|
|
ec07824a4d | ||
|
|
611b3b307c | ||
|
|
bdb1065217 | ||
|
|
6640f09639 | ||
|
|
875feb37a5 | ||
|
|
2efc23cac7 | ||
|
|
134ece2bcc | ||
|
|
408b68ba78 | ||
|
|
a8b04bca12 | ||
|
|
4541b322b3 | ||
|
|
d4000088a5 | ||
|
|
470fc105fc | ||
|
|
148350a353 | ||
|
|
15bdf74701 | ||
|
|
8c0aa6c2ae | ||
|
|
09192205bd | ||
|
|
3b65e135c2 | ||
|
|
722fff29f2 | ||
|
|
fdf1757f88 | ||
|
|
85a3c846e3 | ||
|
|
3e60b85f07 | ||
|
|
978969b271 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,6 +11,7 @@ xcuserdata/
|
|||||||
# Icon must end with two \r
|
# Icon must end with two \r
|
||||||
Icon
|
Icon
|
||||||
|
|
||||||
|
|
||||||
# Thumbnails
|
# Thumbnails
|
||||||
._*
|
._*
|
||||||
|
|
||||||
@@ -509,3 +510,4 @@ dist
|
|||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
trading_assistant_api/services/shared_data/
|
||||||
@@ -5,8 +5,8 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# ===================== 可自定义配置(按需修改) =====================
|
# ===================== 可自定义配置(按需修改) =====================
|
||||||
PROJECT_ROOT = "go-monorepo" # 项目根目录名
|
PROJECT_ROOT = "code" # 项目根目录名
|
||||||
GO_VERSION = "1.21" # 团队统一Go版本
|
GO_VERSION = "1.25.7" # 团队统一Go版本
|
||||||
# ====================================================================
|
# ====================================================================
|
||||||
|
|
||||||
def create_dirs(base_path: Path):
|
def create_dirs(base_path: Path):
|
||||||
@@ -108,14 +108,15 @@ appendfsync everysec
|
|||||||
"""
|
"""
|
||||||
write_file(deploy_path / "redis/redis.conf", redis_conf)
|
write_file(deploy_path / "redis/redis.conf", redis_conf)
|
||||||
|
|
||||||
def generate_docker_compose(base_path: Path):
|
# 【修复】增加project_name参数,接收外部传入的项目名
|
||||||
|
def generate_docker_compose(base_path: Path, project_name: str):
|
||||||
"""生成根目录docker-compose.yml(聚合中间件+业务模板)"""
|
"""生成根目录docker-compose.yml(聚合中间件+业务模板)"""
|
||||||
compose_content = f"""version: '3.8'
|
compose_content = f"""version: '3.8'
|
||||||
services:
|
services:
|
||||||
# 公共PostgresSQL
|
# 公共PostgresSQL
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: {PROJECT_ROOT}-postgres
|
container_name: {project_name}-postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${{PG_USER:-root}}
|
POSTGRES_USER: ${{PG_USER:-root}}
|
||||||
POSTGRES_PASSWORD: ${{PG_PWD:-123456}}
|
POSTGRES_PASSWORD: ${{PG_PWD:-123456}}
|
||||||
@@ -132,7 +133,7 @@ services:
|
|||||||
# 公共Redis
|
# 公共Redis
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: {PROJECT_ROOT}-redis
|
container_name: {project_name}-redis
|
||||||
ports:
|
ports:
|
||||||
- "${{REDIS_PORT:-6379}}:6379"
|
- "${{REDIS_PORT:-6379}}:6379"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -148,7 +149,7 @@ services:
|
|||||||
# build:
|
# build:
|
||||||
# context: ./services/user
|
# context: ./services/user
|
||||||
# dockerfile: Dockerfile
|
# dockerfile: Dockerfile
|
||||||
# container_name: {PROJECT_ROOT}-user
|
# container_name: {project_name}-user
|
||||||
# environment:
|
# environment:
|
||||||
# - GIN_MODE=release
|
# - GIN_MODE=release
|
||||||
# - PG_ADDR=postgres:5432
|
# - PG_ADDR=postgres:5432
|
||||||
@@ -275,8 +276,8 @@ def main():
|
|||||||
generate_common_module(base_path)
|
generate_common_module(base_path)
|
||||||
# 4. 生成部署资源文件
|
# 4. 生成部署资源文件
|
||||||
generate_deploy_files(base_path)
|
generate_deploy_files(base_path)
|
||||||
# 5. 生成Docker Compose
|
# 【修复】调用时传入PROJECT_ROOT作为project_name参数
|
||||||
generate_docker_compose(base_path)
|
generate_docker_compose(base_path, PROJECT_ROOT)
|
||||||
# 6. 生成全局配置(.env/Makefile)
|
# 6. 生成全局配置(.env/Makefile)
|
||||||
generate_global_config(base_path)
|
generate_global_config(base_path)
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ set -euo pipefail
|
|||||||
export LANG=C.UTF-8
|
export LANG=C.UTF-8
|
||||||
|
|
||||||
# 配置项(可按需修改)
|
# 配置项(可按需修改)
|
||||||
PROJECT_NAME="go-monorepo"
|
PROJECT_NAME="code"
|
||||||
PYTHON_IMAGE="python:3.11-alpine" # 轻量Python3镜像,仅80+MB
|
PYTHON_IMAGE="python:3.11-alpine" # 轻量Python3镜像,仅80+MB
|
||||||
PYTHON_SCRIPT="init_monorepo.py"
|
PYTHON_SCRIPT="init_monorepo.py"
|
||||||
|
|
||||||
27
trading_assistant_api/Makefile
Normal file
27
trading_assistant_api/Makefile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
SHELL := /bin/sh
|
||||||
|
|
||||||
|
.PHONY: bootstrap tidy-all run-user run-country test-all
|
||||||
|
|
||||||
|
bootstrap:
|
||||||
|
@echo "Initializing go.work..."
|
||||||
|
@go work init || true
|
||||||
|
@go work use ./common || true
|
||||||
|
@go work use ./services/user || true
|
||||||
|
@go work use ./services/country || true
|
||||||
|
|
||||||
|
tidy-all:
|
||||||
|
@echo "Running go mod tidy for all modules..."
|
||||||
|
@cd common && go mod tidy
|
||||||
|
@cd services/user && go mod tidy
|
||||||
|
@cd services/country && go mod tidy
|
||||||
|
|
||||||
|
run-user:
|
||||||
|
@echo "Starting user service..."
|
||||||
|
@cd services/user && PORT=8080 go run .
|
||||||
|
|
||||||
|
run-country:
|
||||||
|
@echo "Starting country service..."
|
||||||
|
@cd services/country && PORT=8081 go run .
|
||||||
|
|
||||||
|
test-all:
|
||||||
|
@echo "No tests yet"
|
||||||
57
trading_assistant_api/common/auth/jwt.go
Normal file
57
trading_assistant_api/common/auth/jwt.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"common/utils"
|
||||||
|
"common/logger"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenerateToken(userID string) (string, error) {
|
||||||
|
secret := utils.GetEnv("JWT_SECRET", "")
|
||||||
|
if secret == "" {
|
||||||
|
return "", errors.New("jwt_secret_missing")
|
||||||
|
}
|
||||||
|
ttlStr := utils.GetEnv("JWT_TTL", "24h")
|
||||||
|
ttl, err := time.ParseDuration(ttlStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
issuer := utils.GetEnv("JWT_ISSUER", "trading-assistant")
|
||||||
|
now := time.Now()
|
||||||
|
claims := jwt.RegisteredClaims{
|
||||||
|
Subject: userID,
|
||||||
|
Issuer: issuer,
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
signed, err := token.SignedString([]byte(secret))
|
||||||
|
if err == nil {
|
||||||
|
logger.L().Printf("jwt generate success user=%s exp=%s", userID, claims.ExpiresAt.Time.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
return signed, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseToken(tokenStr string) (string, error) {
|
||||||
|
secret := utils.GetEnv("JWT_SECRET", "")
|
||||||
|
if secret == "" {
|
||||||
|
return "", errors.New("jwt_secret_missing")
|
||||||
|
}
|
||||||
|
var claims jwt.RegisteredClaims
|
||||||
|
tkn, err := jwt.ParseWithClaims(tokenStr, &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(secret), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.L().Printf("jwt parse error: %v", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !tkn.Valid {
|
||||||
|
logger.L().Printf("jwt invalid")
|
||||||
|
return "", errors.New("token_invalid")
|
||||||
|
}
|
||||||
|
logger.L().Printf("jwt parse success user=%s exp=%s", claims.Subject, claims.ExpiresAt.Time.Format(time.RFC3339))
|
||||||
|
return claims.Subject, nil
|
||||||
|
}
|
||||||
12
trading_assistant_api/common/codes/codes.go
Normal file
12
trading_assistant_api/common/codes/codes.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package codes
|
||||||
|
|
||||||
|
type Code string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OK Code = "ok"
|
||||||
|
InvalidInput Code = "invalid_input"
|
||||||
|
Unauthorized Code = "unauthorized"
|
||||||
|
Conflict Code = "conflict"
|
||||||
|
InternalError Code = "internal_error"
|
||||||
|
MethodNotAllowed Code = "method_not_allowed"
|
||||||
|
)
|
||||||
10
trading_assistant_api/common/codes/values.go
Normal file
10
trading_assistant_api/common/codes/values.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package codes
|
||||||
|
|
||||||
|
const (
|
||||||
|
ValueOK = 0
|
||||||
|
ValueInvalidInput = 1001
|
||||||
|
ValueUnauthorized = 1002
|
||||||
|
ValueConflict = 1003
|
||||||
|
ValueMethodNotAllowed = 1004
|
||||||
|
ValueInternalError = 1500
|
||||||
|
)
|
||||||
146
trading_assistant_api/common/db/postgres.go
Normal file
146
trading_assistant_api/common/db/postgres.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"common/utils"
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
DBName string
|
||||||
|
SSLMode string
|
||||||
|
MaxOpenConns int
|
||||||
|
MaxIdleConns int
|
||||||
|
ConnMaxLifetime time.Duration
|
||||||
|
ConnMaxIdleTime time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type Option func(*Options)
|
||||||
|
|
||||||
|
func defaultOptions() *Options {
|
||||||
|
return &Options{
|
||||||
|
Host: "localhost",
|
||||||
|
Port: "5432",
|
||||||
|
SSLMode: "disable",
|
||||||
|
MaxOpenConns: 20,
|
||||||
|
MaxIdleConns: 10,
|
||||||
|
ConnMaxLifetime: 30 * time.Minute,
|
||||||
|
ConnMaxIdleTime: 10 * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FromEnv() *Options {
|
||||||
|
return &Options{
|
||||||
|
Host: utils.GetEnv("PG_HOST", ""),
|
||||||
|
Port: utils.GetEnv("PG_PORT", ""),
|
||||||
|
User: utils.GetEnv("PG_USER", ""),
|
||||||
|
Password: utils.GetEnv("PG_PASSWORD", ""),
|
||||||
|
DBName: utils.GetEnv("PG_DBNAME", ""),
|
||||||
|
SSLMode: utils.GetEnv("PG_SSLMODE", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithHost(v string) Option { return func(o *Options) { o.Host = v } }
|
||||||
|
func WithPort(v string) Option { return func(o *Options) { o.Port = v } }
|
||||||
|
func WithUser(v string) Option { return func(o *Options) { o.User = v } }
|
||||||
|
func WithPassword(v string) Option { return func(o *Options) { o.Password = v } }
|
||||||
|
func WithDBName(v string) Option { return func(o *Options) { o.DBName = v } }
|
||||||
|
func WithSSLMode(v string) Option { return func(o *Options) { o.SSLMode = v } }
|
||||||
|
func WithMaxOpenConns(v int) Option { return func(o *Options) { o.MaxOpenConns = v } }
|
||||||
|
func WithMaxIdleConns(v int) Option { return func(o *Options) { o.MaxIdleConns = v } }
|
||||||
|
func WithConnMaxLifetime(v time.Duration) Option {
|
||||||
|
return func(o *Options) { o.ConnMaxLifetime = v }
|
||||||
|
}
|
||||||
|
func WithConnMaxIdleTime(v time.Duration) Option {
|
||||||
|
return func(o *Options) { o.ConnMaxIdleTime = v }
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
pg *sql.DB
|
||||||
|
pgOnce sync.Once
|
||||||
|
pgErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
func InitPostgres(opts ...Option) (*sql.DB, error) {
|
||||||
|
pgOnce.Do(func() {
|
||||||
|
o := defaultOptions()
|
||||||
|
env := FromEnv()
|
||||||
|
merge := func(dst *Options, src *Options) {
|
||||||
|
if src.Host != "" {
|
||||||
|
dst.Host = src.Host
|
||||||
|
}
|
||||||
|
if src.Port != "" {
|
||||||
|
dst.Port = src.Port
|
||||||
|
}
|
||||||
|
if src.User != "" {
|
||||||
|
dst.User = src.User
|
||||||
|
}
|
||||||
|
if src.Password != "" {
|
||||||
|
dst.Password = src.Password
|
||||||
|
}
|
||||||
|
if src.DBName != "" {
|
||||||
|
dst.DBName = src.DBName
|
||||||
|
}
|
||||||
|
if src.SSLMode != "" {
|
||||||
|
dst.SSLMode = src.SSLMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merge(o, env)
|
||||||
|
for _, f := range opts {
|
||||||
|
f(o)
|
||||||
|
}
|
||||||
|
if o.User == "" || o.Password == "" || o.DBName == "" {
|
||||||
|
pgErr = fmt.Errorf("postgres config missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", o.Host, o.Port, o.User, o.Password, o.DBName, o.SSLMode)
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
pgErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(o.MaxOpenConns)
|
||||||
|
db.SetMaxIdleConns(o.MaxIdleConns)
|
||||||
|
db.SetConnMaxLifetime(o.ConnMaxLifetime)
|
||||||
|
db.SetConnMaxIdleTime(o.ConnMaxIdleTime)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
pgErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pg = db
|
||||||
|
pgErr = nil
|
||||||
|
})
|
||||||
|
return pg, pgErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPostgres() (*sql.DB, error) {
|
||||||
|
if pg == nil {
|
||||||
|
return nil, fmt.Errorf("postgres not initialized")
|
||||||
|
}
|
||||||
|
return pg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClosePostgres() error {
|
||||||
|
if pg == nil {
|
||||||
|
return fmt.Errorf("postgres not initialized")
|
||||||
|
}
|
||||||
|
err := pg.Close()
|
||||||
|
pg = nil
|
||||||
|
pgOnce = sync.Once{}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
6
trading_assistant_api/common/go.mod
Normal file
6
trading_assistant_api/common/go.mod
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module common
|
||||||
|
|
||||||
|
go 1.25.7
|
||||||
|
|
||||||
|
require github.com/jackc/pgx/v5 v5.6.0
|
||||||
|
require github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
42
trading_assistant_api/common/httpx/auth.go
Normal file
42
trading_assistant_api/common/httpx/auth.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"common/auth"
|
||||||
|
"common/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type userIDKey struct{}
|
||||||
|
|
||||||
|
func AuthRequired() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ah := r.Header.Get("Authorization")
|
||||||
|
if ah == "" || !strings.HasPrefix(ah, "Bearer ") {
|
||||||
|
logger.WithPrefix("rid="+RequestIDFromContext(r)).Printf("auth missing header path=%s", r.URL.Path)
|
||||||
|
Unauthorized(w, r, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(strings.TrimPrefix(ah, "Bearer "))
|
||||||
|
sub, err := auth.ParseToken(token)
|
||||||
|
if err != nil || sub == "" {
|
||||||
|
logger.WithPrefix("rid="+RequestIDFromContext(r)).Printf("auth invalid token path=%s", r.URL.Path)
|
||||||
|
Unauthorized(w, r, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), userIDKey{}, sub)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserID(r *http.Request) string {
|
||||||
|
v := r.Context().Value(userIDKey{})
|
||||||
|
if id, ok := v.(string); ok {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
19
trading_assistant_api/common/httpx/cors.go
Normal file
19
trading_assistant_api/common/httpx/cors.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package httpx
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func CORS() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With, X-Request-ID")
|
||||||
|
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
43
trading_assistant_api/common/httpx/httpx.go
Normal file
43
trading_assistant_api/common/httpx/httpx.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"common/types"
|
||||||
|
"common/codes"
|
||||||
|
)
|
||||||
|
|
||||||
|
func WriteJSON(w http.ResponseWriter, r *http.Request, status int, ok bool, code int, msg string, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(types.Response{Status: ok, Message: msg, Code: code, RequestID: RequestIDFromContext(r), Data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func OK(w http.ResponseWriter, r *http.Request, data interface{}) {
|
||||||
|
WriteJSON(w, r, http.StatusOK, true, codes.ValueOK, string(codes.OK), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Created(w http.ResponseWriter, r *http.Request, data interface{}) {
|
||||||
|
WriteJSON(w, r, http.StatusCreated, true, codes.ValueOK, string(codes.OK), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BadRequest(w http.ResponseWriter, r *http.Request, msg string) {
|
||||||
|
WriteJSON(w, r, http.StatusBadRequest, false, codes.ValueInvalidInput, msg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Unauthorized(w http.ResponseWriter, r *http.Request, msg string) {
|
||||||
|
WriteJSON(w, r, http.StatusUnauthorized, false, codes.ValueUnauthorized, msg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Conflict(w http.ResponseWriter, r *http.Request, msg string) {
|
||||||
|
WriteJSON(w, r, http.StatusConflict, false, codes.ValueConflict, msg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MethodNotAllowed(w http.ResponseWriter, r *http.Request, msg string) {
|
||||||
|
WriteJSON(w, r, http.StatusMethodNotAllowed, false, codes.ValueMethodNotAllowed, msg, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func InternalError(w http.ResponseWriter, r *http.Request) {
|
||||||
|
WriteJSON(w, r, http.StatusInternalServerError, false, codes.ValueInternalError, string(codes.InternalError), nil)
|
||||||
|
}
|
||||||
47
trading_assistant_api/common/httpx/request_id.go
Normal file
47
trading_assistant_api/common/httpx/request_id.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type reqIDKey struct{}
|
||||||
|
|
||||||
|
func RequestID() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rid := incomingRID(r)
|
||||||
|
if rid == "" {
|
||||||
|
rid = genRID()
|
||||||
|
}
|
||||||
|
w.Header().Set("X-Request-ID", rid)
|
||||||
|
ctx := context.WithValue(r.Context(), reqIDKey{}, rid)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequestIDFromContext(r *http.Request) string {
|
||||||
|
v := r.Context().Value(reqIDKey{})
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func incomingRID(r *http.Request) string {
|
||||||
|
h := r.Header.Get("X-Request-ID")
|
||||||
|
if h == "" {
|
||||||
|
h = r.Header.Get("X-Request-Id")
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func genRID() string {
|
||||||
|
var b [16]byte
|
||||||
|
_, _ = rand.Read(b[:])
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
86
trading_assistant_api/common/logger/logger.go
Normal file
86
trading_assistant_api/common/logger/logger.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Logger interface {
|
||||||
|
Printf(format string, v ...any)
|
||||||
|
Fatalf(format string, v ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
type stdLogger struct {
|
||||||
|
l *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stdLogger) Printf(format string, v ...any) { s.l.Printf(format, v...) }
|
||||||
|
func (s *stdLogger) Fatalf(format string, v ...any) { s.l.Fatalf(format, v...) }
|
||||||
|
|
||||||
|
var defaultLogger Logger = &stdLogger{
|
||||||
|
l: log.New(os.Stdout, "[app] ", log.LstdFlags|log.Lshortfile),
|
||||||
|
}
|
||||||
|
|
||||||
|
func L() Logger {
|
||||||
|
return defaultLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetLogger(l Logger) {
|
||||||
|
if l != nil {
|
||||||
|
defaultLogger = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type prefLogger struct {
|
||||||
|
prefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *prefLogger) Printf(format string, v ...any) {
|
||||||
|
defaultLogger.Printf("%s %s", p.prefix, fmt.Sprintf(format, v...))
|
||||||
|
}
|
||||||
|
func (p *prefLogger) Fatalf(format string, v ...any) {
|
||||||
|
defaultLogger.Fatalf("%s %s", p.prefix, fmt.Sprintf(format, v...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithPrefix(prefix string) Logger {
|
||||||
|
return &prefLogger{prefix: prefix}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetupFile(path string) error {
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("log file path empty")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defaultLogger = &stdLogger{
|
||||||
|
l: log.New(f, "[app] ", log.LstdFlags|log.Lshortfile),
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetupTimezone(tz string) error {
|
||||||
|
if tz == "" {
|
||||||
|
return fmt.Errorf("timezone empty")
|
||||||
|
}
|
||||||
|
loc, err := time.LoadLocation(tz)
|
||||||
|
if err != nil {
|
||||||
|
// fallback for alpine without tzdata
|
||||||
|
switch tz {
|
||||||
|
case "Asia/Shanghai", "PRC", "CST":
|
||||||
|
time.Local = time.FixedZone("CST", 8*3600)
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Local = loc
|
||||||
|
return nil
|
||||||
|
}
|
||||||
9
trading_assistant_api/common/types/response.go
Normal file
9
trading_assistant_api/common/types/response.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
Status bool `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
|
}
|
||||||
11
trading_assistant_api/common/utils/env.go
Normal file
11
trading_assistant_api/common/utils/env.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
func GetEnv(key, def string) string {
|
||||||
|
val := os.Getenv(key)
|
||||||
|
if val == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
20
trading_assistant_api/services/country/Dockerfile
Normal file
20
trading_assistant_api/services/country/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM golang:1.25.7-alpine3.23 AS builder
|
||||||
|
WORKDIR /workspace
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
# copy monorepo workspace and modules needed for country service
|
||||||
|
COPY go.work .
|
||||||
|
COPY common ./common
|
||||||
|
COPY services/country ./services/country
|
||||||
|
|
||||||
|
# download deps and build binary
|
||||||
|
RUN cd services/country && go mod download
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /workspace/bin/country-service ./services/country
|
||||||
|
|
||||||
|
FROM alpine:3.19
|
||||||
|
RUN apk add --no-cache tzdata && adduser -D -u 10002 app
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
USER app
|
||||||
|
COPY --from=builder /workspace/bin/country-service /usr/local/bin/country-service
|
||||||
|
EXPOSE 8081
|
||||||
|
ENTRYPOINT ["/usr/local/bin/country-service"]
|
||||||
7
trading_assistant_api/services/country/go.mod
Normal file
7
trading_assistant_api/services/country/go.mod
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module country
|
||||||
|
|
||||||
|
go 1.25.7
|
||||||
|
|
||||||
|
require common v0.0.0
|
||||||
|
|
||||||
|
replace common => ../../common
|
||||||
64
trading_assistant_api/services/country/main.go
Normal file
64
trading_assistant_api/services/country/main.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"common/logger"
|
||||||
|
"common/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
port := utils.GetEnv("PORT", "8081")
|
||||||
|
if err := logger.SetupTimezone("Asia/Shanghai"); err != nil {
|
||||||
|
logger.L().Printf("setup timezone error: %v", err)
|
||||||
|
}
|
||||||
|
if err := logger.SetupFile(utils.GetEnv("LOG_FILE", "/var/log/app/country.log")); err != nil {
|
||||||
|
logger.L().Printf("setup file logger error: %v", err)
|
||||||
|
}
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: ":" + port,
|
||||||
|
Handler: routes(),
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.L().Printf("country service starting on :%s", port)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.L().Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
logger.L().Printf("country service shutting down...")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
logger.L().Printf("server shutdown: %v", err)
|
||||||
|
}
|
||||||
|
logger.L().Printf("country service exited")
|
||||||
|
}
|
||||||
|
|
||||||
|
func routes() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("country-service v0.1.0"))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprintf(w, "hello from country-service")
|
||||||
|
})
|
||||||
|
return mux
|
||||||
|
}
|
||||||
64
trading_assistant_api/services/docker-compose.yml
Normal file
64
trading_assistant_api/services/docker-compose.yml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18.1-alpine
|
||||||
|
container_name: api_postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres123123
|
||||||
|
POSTGRES_DB: postgres
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
volumes:
|
||||||
|
- ./shared_data/db/:/var/lib/postgresql/data
|
||||||
|
- ../services/user/db/schema.sql:/docker-entrypoint-initdb.d/10_user_schema.sql:ro
|
||||||
|
|
||||||
|
user:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: services/user/Dockerfile
|
||||||
|
image: user-service:latest
|
||||||
|
container_name: user_service
|
||||||
|
environment:
|
||||||
|
TZ: "Asia/Shanghai"
|
||||||
|
PG_HOST: postgres
|
||||||
|
PG_PORT: "5432"
|
||||||
|
PG_USER: postgres
|
||||||
|
PG_PASSWORD: postgres123123
|
||||||
|
PG_DBNAME: postgres
|
||||||
|
PG_SSLMODE: disable
|
||||||
|
MIGRATE_ON_START: "0"
|
||||||
|
PORT: "8080"
|
||||||
|
JWT_SECRET: "change_me_dev_secret"
|
||||||
|
JWT_TTL: "24h"
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./shared_data/logs/:/var/log/app/
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# country:
|
||||||
|
# build:
|
||||||
|
# context: ..
|
||||||
|
# dockerfile: services/country/Dockerfile
|
||||||
|
# image: country-service:latest
|
||||||
|
# container_name: country_service
|
||||||
|
# environment:
|
||||||
|
# PORT: "8081"
|
||||||
|
# ports:
|
||||||
|
# - "8081:8081"
|
||||||
|
# volumes:
|
||||||
|
# - ../shared_data/logs/:/var/log/app/
|
||||||
|
# restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
20
trading_assistant_api/services/user/Dockerfile
Normal file
20
trading_assistant_api/services/user/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM golang:1.25.7-alpine3.23 AS builder
|
||||||
|
WORKDIR /workspace
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
# copy monorepo workspace and modules needed for user service
|
||||||
|
COPY go.work .
|
||||||
|
COPY common ./common
|
||||||
|
COPY services/user ./services/user
|
||||||
|
|
||||||
|
# download deps and build binary
|
||||||
|
RUN cd services/user && go mod download
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /workspace/bin/user-service ./services/user
|
||||||
|
|
||||||
|
FROM alpine:3.19
|
||||||
|
RUN apk add --no-cache tzdata && adduser -D -u 10001 app
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
USER app
|
||||||
|
COPY --from=builder /workspace/bin/user-service /usr/local/bin/user-service
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/usr/local/bin/user-service"]
|
||||||
77
trading_assistant_api/services/user/db/schema.sql
Normal file
77
trading_assistant_api/services/user/db/schema.sql
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
-- 1. 创建用户基础信息表
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
user_id UUID PRIMARY KEY DEFAULT uuidv7(),
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE users IS '用户唯一标识信息表';
|
||||||
|
COMMENT ON COLUMN users.user_id IS '用户唯一标识,主键,自动生成UUIDv7';
|
||||||
|
COMMENT ON COLUMN users.deleted IS '逻辑删除标识:false-未删除,true-已删除';
|
||||||
|
COMMENT ON COLUMN users.create_time IS '记录创建时间(带时区)';
|
||||||
|
COMMENT ON COLUMN users.update_time IS '记录更新时间(带时区)';
|
||||||
|
|
||||||
|
-- 2. 创建用户登录账户表
|
||||||
|
CREATE TABLE IF NOT EXISTS user_login_accounts (
|
||||||
|
follow_id UUID PRIMARY KEY DEFAULT uuidv7(),
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
value VARCHAR(100) NOT NULL,
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_user_login_accounts_user_id FOREIGN KEY (user_id) REFERENCES users(user_id),
|
||||||
|
CONSTRAINT uk_user_login_accounts_value UNIQUE (value, deleted)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE user_login_accounts IS '用户登录账户表';
|
||||||
|
COMMENT ON COLUMN user_login_accounts.follow_id IS '记录唯一标识,主键,自动生成UUIDv7';
|
||||||
|
COMMENT ON COLUMN user_login_accounts.user_id IS '关联用户ID,外键关联users表';
|
||||||
|
COMMENT ON COLUMN user_login_accounts.value IS '登录账户值(手机号/邮箱/用户名)';
|
||||||
|
COMMENT ON COLUMN user_login_accounts.deleted IS '逻辑删除标识:false-未删除,true-已删除';
|
||||||
|
COMMENT ON COLUMN user_login_accounts.create_time IS '记录创建时间(带时区)';
|
||||||
|
COMMENT ON COLUMN user_login_accounts.update_time IS '记录更新时间(带时区)';
|
||||||
|
|
||||||
|
-- 3. 创建用户登录密码表
|
||||||
|
CREATE TABLE IF NOT EXISTS user_login_passwords (
|
||||||
|
follow_id UUID PRIMARY KEY DEFAULT uuidv7(),
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
value VARCHAR(255) NOT NULL,
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_user_login_passwords_user_id FOREIGN KEY (user_id) REFERENCES users(user_id),
|
||||||
|
CONSTRAINT uk_user_login_passwords_user_id UNIQUE (user_id, deleted)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE user_login_passwords IS '用户登录密码表(存储加密后的密码)';
|
||||||
|
COMMENT ON COLUMN user_login_passwords.follow_id IS '记录唯一标识,主键,自动生成UUIDv7';
|
||||||
|
COMMENT ON COLUMN user_login_passwords.user_id IS '关联用户ID,外键关联users表';
|
||||||
|
COMMENT ON COLUMN user_login_passwords.value IS '加密后的登录密码(建议使用bcrypt/argon2等算法)';
|
||||||
|
COMMENT ON COLUMN user_login_passwords.deleted IS '逻辑删除标识:false-未删除,true-已删除';
|
||||||
|
COMMENT ON COLUMN user_login_passwords.create_time IS '记录创建时间(带时区)';
|
||||||
|
COMMENT ON COLUMN user_login_passwords.update_time IS '记录更新时间(带时区)';
|
||||||
|
|
||||||
|
-- 4. 创建update_time自动更新触发器
|
||||||
|
CREATE OR REPLACE FUNCTION update_timestamp()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.update_time = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trigger_users_update_time
|
||||||
|
BEFORE UPDATE ON users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER trigger_user_login_accounts_update_time
|
||||||
|
BEFORE UPDATE ON user_login_accounts
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER trigger_user_login_passwords_update_time
|
||||||
|
BEFORE UPDATE ON user_login_passwords
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_timestamp();
|
||||||
8
trading_assistant_api/services/user/go.mod
Normal file
8
trading_assistant_api/services/user/go.mod
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
module user
|
||||||
|
|
||||||
|
go 1.25.7
|
||||||
|
|
||||||
|
require common v0.0.0
|
||||||
|
require golang.org/x/crypto v0.31.0
|
||||||
|
|
||||||
|
replace common => ../../common
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"common/httpx"
|
||||||
|
"common/codes"
|
||||||
|
"common/logger"
|
||||||
|
"user/internal/model"
|
||||||
|
"user/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
S *service.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(s *service.Service) *Handler {
|
||||||
|
return &Handler{S: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("method not allowed path=%s", r.URL.Path)
|
||||||
|
httpx.MethodNotAllowed(w, r, string(codes.MethodNotAllowed))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req model.RegisterReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("invalid json path=%s", r.URL.Path)
|
||||||
|
httpx.BadRequest(w, r, "invalid json")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, token, err := h.S.Register(req.Account, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case service.ErrInvalidInput:
|
||||||
|
logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("invalid input account=%s", req.Account)
|
||||||
|
httpx.BadRequest(w, r, "invalid account or password")
|
||||||
|
case service.ErrAccountExists:
|
||||||
|
logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("account exists account=%s", req.Account)
|
||||||
|
httpx.Conflict(w, r, "account exists")
|
||||||
|
default:
|
||||||
|
logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("internal error account=%s", req.Account)
|
||||||
|
httpx.InternalError(w, r)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("register success user=%s", userID)
|
||||||
|
httpx.Created(w, r, map[string]string{"user_id": userID, "token": token})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("method not allowed path=%s", r.URL.Path)
|
||||||
|
httpx.MethodNotAllowed(w, r, string(codes.MethodNotAllowed))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req model.LoginReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("invalid json path=%s", r.URL.Path)
|
||||||
|
httpx.BadRequest(w, r, "invalid json")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, token, err := h.S.Login(req.Account, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case service.ErrInvalidInput:
|
||||||
|
logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("invalid input account=%s", req.Account)
|
||||||
|
httpx.BadRequest(w, r, "invalid account or password")
|
||||||
|
case service.ErrUnauthorized:
|
||||||
|
logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("unauthorized account=%s", req.Account)
|
||||||
|
httpx.Unauthorized(w, r, "unauthorized")
|
||||||
|
default:
|
||||||
|
logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("internal error account=%s", req.Account)
|
||||||
|
httpx.InternalError(w, r)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("login success user=%s", userID)
|
||||||
|
httpx.OK(w, r, map[string]string{"user_id": userID, "token": token})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("method not allowed path=%s", r.URL.Path)
|
||||||
|
httpx.MethodNotAllowed(w, r, string(codes.MethodNotAllowed))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("healthz ok")
|
||||||
|
httpx.OK(w, r, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Version(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("method not allowed path=%s", r.URL.Path)
|
||||||
|
httpx.MethodNotAllowed(w, r, string(codes.MethodNotAllowed))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("version ok")
|
||||||
|
httpx.OK(w, r, map[string]string{"version": "user-service v0.1.0"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Root(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("method not allowed path=%s", r.URL.Path)
|
||||||
|
httpx.MethodNotAllowed(w, r, string(codes.MethodNotAllowed))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("root ok user=%s", httpx.UserID(r))
|
||||||
|
httpx.OK(w, r, map[string]string{"service": "user", "user_id": httpx.UserID(r)})
|
||||||
|
}
|
||||||
11
trading_assistant_api/services/user/internal/model/types.go
Normal file
11
trading_assistant_api/services/user/internal/model/types.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type RegisterReq struct {
|
||||||
|
Account string `json:"account"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginReq struct {
|
||||||
|
Account string `json:"account"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repo struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *sql.DB) *Repo {
|
||||||
|
return &Repo{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) CreateUser(tx *sql.Tx) (string, error) {
|
||||||
|
var userID string
|
||||||
|
if err := tx.QueryRow(`INSERT INTO users DEFAULT VALUES RETURNING user_id`).Scan(&userID); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) CreateLoginAccount(tx *sql.Tx, userID, account string) error {
|
||||||
|
_, err := tx.Exec(`INSERT INTO user_login_accounts (user_id, value, deleted) VALUES ($1, $2, false)`, userID, account)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) CreateLoginPassword(tx *sql.Tx, userID, hashed string) error {
|
||||||
|
_, err := tx.Exec(`INSERT INTO user_login_passwords (user_id, value, deleted) VALUES ($1, $2, false)`, userID, hashed)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) GetUserIDByAccount(account string) (string, error) {
|
||||||
|
var userID string
|
||||||
|
if err := r.DB.QueryRow(`SELECT user_id FROM user_login_accounts WHERE value = $1 AND deleted = false`, account).Scan(&userID); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) GetHashedPassword(userID string) (string, error) {
|
||||||
|
var hashed string
|
||||||
|
if err := r.DB.QueryRow(`SELECT value FROM user_login_passwords WHERE user_id = $1 AND deleted = false`, userID).Scan(&hashed); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hashed, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"common/httpx"
|
||||||
|
"user/internal/handler"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(h *handler.Handler) http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/healthz", h.Healthz)
|
||||||
|
mux.HandleFunc("/version", h.Version)
|
||||||
|
mux.Handle("/", httpx.AuthRequired()(http.HandlerFunc(h.Root)))
|
||||||
|
mux.HandleFunc("/register", h.Register)
|
||||||
|
mux.HandleFunc("/login", h.Login)
|
||||||
|
return mux
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"common/auth"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"user/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidInput = errors.New("invalid_input")
|
||||||
|
ErrAccountExists = errors.New("account_exists")
|
||||||
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
Repo *repository.Repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(repo *repository.Repo) *Service {
|
||||||
|
return &Service{Repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Register(account, password string) (string, string, error) {
|
||||||
|
if !validAccount(account) || !validPassword(password) {
|
||||||
|
return "", "", ErrInvalidInput
|
||||||
|
}
|
||||||
|
hashed, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
tx, err := s.Repo.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
userID, err := s.Repo.CreateUser(tx)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if err := s.Repo.CreateLoginAccount(tx, userID, account); err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return "", "", ErrAccountExists
|
||||||
|
}
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if err := s.Repo.CreateLoginPassword(tx, userID, string(hashed)); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
tkn, err := auth.GenerateToken(userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return userID, tkn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Login(account, password string) (string, string, error) {
|
||||||
|
if !validAccount(account) || !validPassword(password) {
|
||||||
|
return "", "", ErrInvalidInput
|
||||||
|
}
|
||||||
|
userID, err := s.Repo.GetUserIDByAccount(account)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", ErrUnauthorized
|
||||||
|
}
|
||||||
|
hashed, err := s.Repo.GetHashedPassword(userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", ErrUnauthorized
|
||||||
|
}
|
||||||
|
if bcrypt.CompareHashAndPassword([]byte(hashed), []byte(password)) != nil {
|
||||||
|
return "", "", ErrUnauthorized
|
||||||
|
}
|
||||||
|
tkn, err := auth.GenerateToken(userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return userID, tkn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validAccount(a string) bool {
|
||||||
|
n := len(a)
|
||||||
|
return n >= 3 && n <= 100
|
||||||
|
}
|
||||||
|
|
||||||
|
func validPassword(p string) bool {
|
||||||
|
n := len(p)
|
||||||
|
return n >= 8 && n <= 128
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUniqueViolation(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s := err.Error()
|
||||||
|
return strings.Contains(s, "duplicate key") || strings.Contains(s, "unique constraint") || strings.Contains(s, "23505")
|
||||||
|
}
|
||||||
86
trading_assistant_api/services/user/main.go
Normal file
86
trading_assistant_api/services/user/main.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"database/sql"
|
||||||
|
"common/db"
|
||||||
|
"common/logger"
|
||||||
|
"common/utils"
|
||||||
|
"common/httpx"
|
||||||
|
"user/internal/handler"
|
||||||
|
"user/internal/repository"
|
||||||
|
"user/internal/router"
|
||||||
|
"user/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pg *sql.DB
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
port := utils.GetEnv("PORT", "8080")
|
||||||
|
if err := logger.SetupTimezone("Asia/Shanghai"); err != nil {
|
||||||
|
logger.L().Printf("setup timezone error: %v", err)
|
||||||
|
}
|
||||||
|
if err := logger.SetupFile(utils.GetEnv("LOG_FILE", "/var/log/app/user.log")); err != nil {
|
||||||
|
logger.L().Printf("setup file logger error: %v", err)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
pg, err = db.InitPostgres()
|
||||||
|
if err != nil {
|
||||||
|
logger.L().Fatalf("postgres init: %v", err)
|
||||||
|
}
|
||||||
|
if utils.GetEnv("MIGRATE_ON_START", "0") == "1" {
|
||||||
|
if err := applySchema("db/schema.sql"); err != nil {
|
||||||
|
logger.L().Fatalf("apply schema: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: ":" + port,
|
||||||
|
Handler: routes(),
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.L().Printf("user service starting on :%s", port)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.L().Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
logger.L().Printf("user service shutting down...")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
logger.L().Printf("server shutdown: %v", err)
|
||||||
|
}
|
||||||
|
_ = db.ClosePostgres()
|
||||||
|
logger.L().Printf("user service exited")
|
||||||
|
}
|
||||||
|
|
||||||
|
func routes() http.Handler {
|
||||||
|
repo := repository.New(pg)
|
||||||
|
svc := service.New(repo)
|
||||||
|
h := handler.New(svc)
|
||||||
|
cors := httpx.CORS()
|
||||||
|
reqID := httpx.RequestID()
|
||||||
|
return reqID(cors(router.New(h)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySchema(path string) error {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = pg.Exec(string(b))
|
||||||
|
return err
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user