Compare commits

..

42 Commits

Author SHA1 Message Date
vipg
292c37a177 add 2026-02-10 12:19:41 +08:00
vipg
2b2fd162a2 add 2026-02-10 11:20:57 +08:00
vipg
1aece314c6 add 2026-02-10 11:15:03 +08:00
vipg
b3ac9e8060 add 2026-02-10 11:01:17 +08:00
vipg
8eb9adda68 add 2026-02-10 10:57:11 +08:00
vipg
a33e6be08f add 2026-02-10 10:53:26 +08:00
vipg
919ef704af add 2026-02-10 10:44:54 +08:00
vipg
5cfbbffb77 add 2026-02-10 10:29:08 +08:00
vipg
1cbd043297 add 2026-02-10 10:27:54 +08:00
vipg
5da272af2e add 2026-02-10 10:24:54 +08:00
vipg
25e566450e add 2026-02-10 10:19:43 +08:00
vipg
32584e25b2 add 2026-02-09 17:59:34 +08:00
vipg
66c1ee9620 add 2026-02-09 17:55:56 +08:00
vipg
cc8dfdad80 add 2026-02-09 17:54:46 +08:00
vipg
65353ee4a2 add 2026-02-09 17:54:00 +08:00
vipg
2ae47c5049 add 2026-02-09 17:50:39 +08:00
vipg
c7b11dca35 add 2026-02-09 17:43:42 +08:00
vipg
25c4628b5f add 2026-02-09 17:29:14 +08:00
vipg
525f2a6846 add 2026-02-09 17:12:20 +08:00
vipg
e9457b8a24 add 2026-02-09 17:04:20 +08:00
vipg
ec07824a4d add 2026-02-09 16:59:04 +08:00
vipg
611b3b307c add 2026-02-09 16:56:10 +08:00
vipg
bdb1065217 add 2026-02-09 16:49:18 +08:00
vipg
6640f09639 add 2026-02-09 16:45:40 +08:00
vipg
875feb37a5 add 2026-02-09 16:38:59 +08:00
vipg
2efc23cac7 add 2026-02-09 16:32:14 +08:00
vipg
134ece2bcc add 2026-02-09 16:24:50 +08:00
vipg
408b68ba78 add 2026-02-09 16:11:18 +08:00
vipg
a8b04bca12 add 2026-02-09 16:09:14 +08:00
vipg
4541b322b3 update 2026-02-09 16:03:19 +08:00
vipg
d4000088a5 add 2026-02-06 18:18:47 +08:00
vipg
470fc105fc add 2026-02-06 17:27:30 +08:00
vipg
148350a353 add 2026-02-06 17:01:58 +08:00
vipg
15bdf74701 add 2026-02-06 16:57:42 +08:00
vipg
8c0aa6c2ae add 2026-02-06 16:51:09 +08:00
vipg
09192205bd add 2026-02-06 16:48:36 +08:00
vipg
3b65e135c2 add 2026-02-06 16:44:33 +08:00
vipg
722fff29f2 add 2026-02-06 16:36:59 +08:00
vipg
fdf1757f88 add 2026-02-06 15:51:22 +08:00
vipg
85a3c846e3 add 2026-02-06 15:49:59 +08:00
vipg
3e60b85f07 add 2026-02-06 15:46:35 +08:00
vipg
978969b271 add 2026-02-06 15:46:20 +08:00
29 changed files with 1162 additions and 10 deletions

2
.gitignore vendored
View File

@@ -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/

View File

@@ -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)

View File

@@ -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"

View 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"

View 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
}

View 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"
)

View File

@@ -0,0 +1,10 @@
package codes
const (
ValueOK = 0
ValueInvalidInput = 1001
ValueUnauthorized = 1002
ValueConflict = 1003
ValueMethodNotAllowed = 1004
ValueInternalError = 1500
)

View 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
}

View 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

View 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 ""
}

View 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)
})
}
}

View 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)
}

View 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[:])
}

View 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
}

View 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"`
}

View 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
}

View 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"]

View File

@@ -0,0 +1,7 @@
module country
go 1.25.7
require common v0.0.0
replace common => ../../common

View 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
}

View 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:

View 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"]

View 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();

View 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

View File

@@ -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)})
}

View 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"`
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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")
}

View 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
}