Compare commits

..

14 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
16 changed files with 236 additions and 105 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

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

View File

@@ -6,6 +6,7 @@ import (
"strings" "strings"
"common/auth" "common/auth"
"common/logger"
) )
type userIDKey struct{} type userIDKey struct{}
@@ -15,13 +16,15 @@ func AuthRequired() func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ah := r.Header.Get("Authorization") ah := r.Header.Get("Authorization")
if ah == "" || !strings.HasPrefix(ah, "Bearer ") { if ah == "" || !strings.HasPrefix(ah, "Bearer ") {
Unauthorized(w, "unauthorized") logger.WithPrefix("rid="+RequestIDFromContext(r)).Printf("auth missing header path=%s", r.URL.Path)
Unauthorized(w, r, "unauthorized")
return return
} }
token := strings.TrimSpace(strings.TrimPrefix(ah, "Bearer ")) token := strings.TrimSpace(strings.TrimPrefix(ah, "Bearer "))
sub, err := auth.ParseToken(token) sub, err := auth.ParseToken(token)
if err != nil || sub == "" { if err != nil || sub == "" {
Unauthorized(w, "unauthorized") logger.WithPrefix("rid="+RequestIDFromContext(r)).Printf("auth invalid token path=%s", r.URL.Path)
Unauthorized(w, r, "unauthorized")
return return
} }
ctx := context.WithValue(r.Context(), userIDKey{}, sub) ctx := context.WithValue(r.Context(), userIDKey{}, sub)

View File

@@ -8,36 +8,36 @@ import (
"common/codes" "common/codes"
) )
func WriteJSON(w http.ResponseWriter, status int, ok bool, msg string, data interface{}) { 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.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
json.NewEncoder(w).Encode(types.Response{Status: ok, Message: msg, Data: data}) json.NewEncoder(w).Encode(types.Response{Status: ok, Message: msg, Code: code, RequestID: RequestIDFromContext(r), Data: data})
} }
func OK(w http.ResponseWriter, data interface{}) { func OK(w http.ResponseWriter, r *http.Request, data interface{}) {
WriteJSON(w, http.StatusOK, true, string(codes.OK), data) WriteJSON(w, r, http.StatusOK, true, codes.ValueOK, string(codes.OK), data)
} }
func Created(w http.ResponseWriter, data interface{}) { func Created(w http.ResponseWriter, r *http.Request, data interface{}) {
WriteJSON(w, http.StatusCreated, true, string(codes.OK), data) WriteJSON(w, r, http.StatusCreated, true, codes.ValueOK, string(codes.OK), data)
} }
func BadRequest(w http.ResponseWriter, msg string) { func BadRequest(w http.ResponseWriter, r *http.Request, msg string) {
WriteJSON(w, http.StatusBadRequest, false, msg, map[string]string{"code": string(codes.InvalidInput)}) WriteJSON(w, r, http.StatusBadRequest, false, codes.ValueInvalidInput, msg, nil)
} }
func Unauthorized(w http.ResponseWriter, msg string) { func Unauthorized(w http.ResponseWriter, r *http.Request, msg string) {
WriteJSON(w, http.StatusUnauthorized, false, msg, map[string]string{"code": string(codes.Unauthorized)}) WriteJSON(w, r, http.StatusUnauthorized, false, codes.ValueUnauthorized, msg, nil)
} }
func Conflict(w http.ResponseWriter, msg string) { func Conflict(w http.ResponseWriter, r *http.Request, msg string) {
WriteJSON(w, http.StatusConflict, false, msg, map[string]string{"code": string(codes.Conflict)}) WriteJSON(w, r, http.StatusConflict, false, codes.ValueConflict, msg, nil)
} }
func MethodNotAllowed(w http.ResponseWriter, msg string) { func MethodNotAllowed(w http.ResponseWriter, r *http.Request, msg string) {
WriteJSON(w, http.StatusMethodNotAllowed, false, msg, map[string]string{"code": string(codes.MethodNotAllowed)}) WriteJSON(w, r, http.StatusMethodNotAllowed, false, codes.ValueMethodNotAllowed, msg, nil)
} }
func InternalError(w http.ResponseWriter) { func InternalError(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusInternalServerError, false, string(codes.InternalError), map[string]string{"code": string(codes.InternalError)}) WriteJSON(w, r, http.StatusInternalServerError, false, codes.ValueInternalError, string(codes.InternalError), nil)
} }

View File

@@ -3,6 +3,9 @@ package logger
import ( import (
"log" "log"
"os" "os"
"fmt"
"path/filepath"
"time"
) )
type Logger interface { type Logger interface {
@@ -30,3 +33,54 @@ func SetLogger(l Logger) {
defaultLogger = l 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

@@ -3,5 +3,7 @@ package types
type Response struct { type Response struct {
Status bool `json:"status"` Status bool `json:"status"`
Message string `json:"message"` Message string `json:"message"`
Code int `json:"code"`
RequestID string `json:"request_id"`
Data interface{} `json:"data,omitempty"` Data interface{} `json:"data,omitempty"`
} }

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

@@ -15,6 +15,12 @@ import (
func main() { func main() {
port := utils.GetEnv("PORT", "8081") 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{ srv := &http.Server{
Addr: ":" + port, Addr: ":" + port,
Handler: routes(), Handler: routes(),

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

@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1
FROM golang:1.25.7-alpine3.23 AS builder FROM golang:1.25.7-alpine3.23 AS builder
WORKDIR /workspace WORKDIR /workspace
RUN apk add --no-cache git RUN apk add --no-cache git
@@ -14,7 +12,8 @@ RUN cd services/user && go mod download
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /workspace/bin/user-service ./services/user RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /workspace/bin/user-service ./services/user
FROM alpine:3.19 FROM alpine:3.19
RUN adduser -D -u 10001 app RUN apk add --no-cache tzdata && adduser -D -u 10001 app
ENV TZ=Asia/Shanghai
USER app USER app
COPY --from=builder /workspace/bin/user-service /usr/local/bin/user-service COPY --from=builder /workspace/bin/user-service /usr/local/bin/user-service
EXPOSE 8080 EXPOSE 8080

View File

@@ -6,7 +6,7 @@ CREATE TABLE IF NOT EXISTS users (
update_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 TABLE users IS '用户唯一标识信息表';
COMMENT ON COLUMN users.user_id IS '用户唯一标识主键自动生成UUIDv7'; COMMENT ON COLUMN users.user_id IS '用户唯一标识主键自动生成UUIDv7';
COMMENT ON COLUMN users.deleted IS '逻辑删除标识false-未删除true-已删除'; COMMENT ON COLUMN users.deleted IS '逻辑删除标识false-未删除true-已删除';
COMMENT ON COLUMN users.create_time IS '记录创建时间(带时区)'; COMMENT ON COLUMN users.create_time IS '记录创建时间(带时区)';
@@ -24,7 +24,7 @@ CREATE TABLE IF NOT EXISTS user_login_accounts (
CONSTRAINT uk_user_login_accounts_value UNIQUE (value, deleted) CONSTRAINT uk_user_login_accounts_value UNIQUE (value, deleted)
); );
COMMENT ON TABLE user_login_accounts IS '用户登录账户表(手机号/邮箱/用户名等)'; COMMENT ON TABLE user_login_accounts IS '用户登录账户表';
COMMENT ON COLUMN user_login_accounts.follow_id IS '记录唯一标识主键自动生成UUIDv7'; 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.user_id IS '关联用户ID外键关联users表';
COMMENT ON COLUMN user_login_accounts.value IS '登录账户值(手机号/邮箱/用户名)'; COMMENT ON COLUMN user_login_accounts.value IS '登录账户值(手机号/邮箱/用户名)';

View File

@@ -1,9 +0,0 @@
#!/usr/bin/env sh
set -e
ROOT_DIR="$(cd "$(dirname "$0")"/../.. && pwd)"
cd "$ROOT_DIR"
docker build -f services/user/Dockerfile -t user-service:latest .
docker save user-service:latest -o services/user/user-service.tar
docker load -i services/user/user-service.tar
cd services/user
docker compose up -d

View File

@@ -1,44 +0,0 @@
version: "3.9"
services:
postgres:
image: postgres:18.1-alpine
container_name: user_postgres
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app123
POSTGRES_DB: appdb
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 5s
retries: 20
volumes:
- pgdata:/var/lib/postgresql/data
- ./db/schema.sql:/docker-entrypoint-initdb.d/10_user_schema.sql:ro
user:
image: user-service:latest
container_name: user_service
environment:
PG_HOST: postgres
PG_PORT: "5432"
PG_USER: app
PG_PASSWORD: app123
PG_DBNAME: appdb
PG_SSLMODE: disable
MIGRATE_ON_START: "0"
PORT: "8080"
JWT_SECRET: "change_me_dev_secret"
JWT_TTL: "24h"
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
volumes:
pgdata:

View File

@@ -6,6 +6,7 @@ import (
"common/httpx" "common/httpx"
"common/codes" "common/codes"
"common/logger"
"user/internal/model" "user/internal/model"
"user/internal/service" "user/internal/service"
) )
@@ -20,74 +21,92 @@ func New(s *service.Service) *Handler {
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
httpx.MethodNotAllowed(w, string(codes.MethodNotAllowed)) logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("method not allowed path=%s", r.URL.Path)
httpx.MethodNotAllowed(w, r, string(codes.MethodNotAllowed))
return return
} }
var req model.RegisterReq var req model.RegisterReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.BadRequest(w, "invalid json") logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("invalid json path=%s", r.URL.Path)
httpx.BadRequest(w, r, "invalid json")
return return
} }
userID, token, err := h.S.Register(req.Account, req.Password) userID, token, err := h.S.Register(req.Account, req.Password)
if err != nil { if err != nil {
switch err { switch err {
case service.ErrInvalidInput: case service.ErrInvalidInput:
httpx.BadRequest(w, "invalid account or password") logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("invalid input account=%s", req.Account)
httpx.BadRequest(w, r, "invalid account or password")
case service.ErrAccountExists: case service.ErrAccountExists:
httpx.Conflict(w, "account exists") logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("account exists account=%s", req.Account)
httpx.Conflict(w, r, "account exists")
default: default:
httpx.InternalError(w) logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("internal error account=%s", req.Account)
httpx.InternalError(w, r)
} }
return return
} }
httpx.Created(w, map[string]string{"user_id": userID, "token": token}) 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) { func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
httpx.MethodNotAllowed(w, string(codes.MethodNotAllowed)) logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("method not allowed path=%s", r.URL.Path)
httpx.MethodNotAllowed(w, r, string(codes.MethodNotAllowed))
return return
} }
var req model.LoginReq var req model.LoginReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.BadRequest(w, "invalid json") logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("invalid json path=%s", r.URL.Path)
httpx.BadRequest(w, r, "invalid json")
return return
} }
userID, token, err := h.S.Login(req.Account, req.Password) userID, token, err := h.S.Login(req.Account, req.Password)
if err != nil { if err != nil {
switch err { switch err {
case service.ErrInvalidInput: case service.ErrInvalidInput:
httpx.BadRequest(w, "invalid account or password") logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("invalid input account=%s", req.Account)
httpx.BadRequest(w, r, "invalid account or password")
case service.ErrUnauthorized: case service.ErrUnauthorized:
httpx.Unauthorized(w, "unauthorized") logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("unauthorized account=%s", req.Account)
httpx.Unauthorized(w, r, "unauthorized")
default: default:
httpx.InternalError(w) logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("internal error account=%s", req.Account)
httpx.InternalError(w, r)
} }
return return
} }
httpx.OK(w, map[string]string{"user_id": userID, "token": token}) 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) { func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
httpx.MethodNotAllowed(w, string(codes.MethodNotAllowed)) logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("method not allowed path=%s", r.URL.Path)
httpx.MethodNotAllowed(w, r, string(codes.MethodNotAllowed))
return return
} }
httpx.OK(w, nil) logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("healthz ok")
httpx.OK(w, r, nil)
} }
func (h *Handler) Version(w http.ResponseWriter, r *http.Request) { func (h *Handler) Version(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
httpx.MethodNotAllowed(w, string(codes.MethodNotAllowed)) logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("method not allowed path=%s", r.URL.Path)
httpx.MethodNotAllowed(w, r, string(codes.MethodNotAllowed))
return return
} }
httpx.OK(w, map[string]string{"version": "user-service v0.1.0"}) 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) { func (h *Handler) Root(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
httpx.MethodNotAllowed(w, string(codes.MethodNotAllowed)) logger.WithPrefix("rid="+httpx.RequestIDFromContext(r)).Printf("method not allowed path=%s", r.URL.Path)
httpx.MethodNotAllowed(w, r, string(codes.MethodNotAllowed))
return return
} }
httpx.OK(w, map[string]string{"service": "user", "user_id": httpx.UserID(r)}) 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

@@ -1,11 +1,10 @@
package service package service
import ( import (
"database/sql"
"errors" "errors"
"strings"
"common/auth" "common/auth"
"github.com/jackc/pgconn"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"user/internal/repository" "user/internal/repository"
) )
@@ -93,9 +92,9 @@ func validPassword(p string) bool {
} }
func isUniqueViolation(err error) bool { func isUniqueViolation(err error) bool {
var pe *pgconn.PgError if err == nil {
if errors.As(err, &pe) { return false
return pe.Code == "23505"
} }
return false s := err.Error()
return strings.Contains(s, "duplicate key") || strings.Contains(s, "unique constraint") || strings.Contains(s, "23505")
} }

View File

@@ -23,11 +23,12 @@ var pg *sql.DB
func main() { func main() {
port := utils.GetEnv("PORT", "8080") port := utils.GetEnv("PORT", "8080")
srv := &http.Server{ if err := logger.SetupTimezone("Asia/Shanghai"); err != nil {
Addr: ":" + port, logger.L().Printf("setup timezone error: %v", err)
Handler: routes(), }
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 var err error
pg, err = db.InitPostgres() pg, err = db.InitPostgres()
if err != nil { if err != nil {
@@ -39,6 +40,11 @@ func main() {
} }
} }
srv := &http.Server{
Addr: ":" + port,
Handler: routes(),
}
logger.L().Printf("user service starting on :%s", port) logger.L().Printf("user service starting on :%s", port)
go func() { go func() {