优化结构
This commit is contained in:
@@ -1,69 +0,0 @@
|
||||
// Package httpx contains the stable JSON response contract shared by HTTP
|
||||
// modules. It intentionally keeps the existing Gin adapter so callers can
|
||||
// migrate without changing their handler flow.
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
CodeSuccess = 0
|
||||
CodeError = 7
|
||||
CodePasswordChangeRequired = 10001
|
||||
)
|
||||
|
||||
type Response struct {
|
||||
Code int `json:"code"`
|
||||
Data any `json:"data"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type PageResult struct {
|
||||
List any `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
func Write(c *gin.Context, code int, data any, message string) {
|
||||
c.JSON(http.StatusOK, Response{Code: code, Data: data, Msg: message})
|
||||
}
|
||||
|
||||
func OK(c *gin.Context) { Write(c, CodeSuccess, gin.H{}, "操作成功") }
|
||||
|
||||
func OKWithData(c *gin.Context, data any) { Write(c, CodeSuccess, data, "成功") }
|
||||
|
||||
func Fail(c *gin.Context, message string) { Write(c, CodeError, gin.H{}, message) }
|
||||
|
||||
func NoAuth(c *gin.Context, message string) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, Response{Code: CodeError, Data: nil, Msg: message})
|
||||
}
|
||||
|
||||
func requestUsesHTTPS(request *http.Request) bool {
|
||||
if request != nil && request.TLS != nil {
|
||||
return true
|
||||
}
|
||||
if request == nil {
|
||||
return false
|
||||
}
|
||||
forwarded := strings.SplitN(request.Header.Get("X-Forwarded-Proto"), ",", 2)[0]
|
||||
return strings.EqualFold(strings.TrimSpace(forwarded), "https")
|
||||
}
|
||||
|
||||
// SetTokenCookie is the shared transport-level cookie policy. It has no KRA
|
||||
// business dependency, so handlers and middleware can use pkg/httpx directly.
|
||||
func SetTokenCookie(c *gin.Context, value string, maxAge int) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
cookie := &http.Cookie{Name: "x-token", Value: value, Path: "/", MaxAge: maxAge, Secure: requestUsesHTTPS(c.Request), HttpOnly: true, SameSite: http.SameSiteStrictMode}
|
||||
if maxAge < 0 {
|
||||
cookie.Expires = time.Unix(1, 0)
|
||||
}
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestResponseHelpers(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/ok", OK)
|
||||
r.GET("/fail", func(c *gin.Context) { Fail(c, "bad") })
|
||||
r.GET("/auth", func(c *gin.Context) { NoAuth(c, "login") })
|
||||
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
statusCode int
|
||||
body string
|
||||
}{
|
||||
{"/ok", 200, `{"code":0,"data":{},"msg":"操作成功"}`},
|
||||
{"/fail", 200, `{"code":7,"data":{},"msg":"bad"}`},
|
||||
{"/auth", 401, `{"code":7,"data":null,"msg":"login"}`},
|
||||
} {
|
||||
req := httptest.NewRequest("GET", tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != tc.statusCode || w.Body.String() != tc.body {
|
||||
t.Fatalf("%s: status=%d body=%q", tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type contextFieldsKey struct{}
|
||||
|
||||
// ContextFields contains request metadata attached to logs emitted with a
|
||||
// request context.
|
||||
type ContextFields struct {
|
||||
RequestID string
|
||||
TraceID string
|
||||
SpanID string
|
||||
ParentSpanID string
|
||||
DeviceID string
|
||||
ClientIP string
|
||||
HTTPMethod string
|
||||
HTTPPath string
|
||||
}
|
||||
|
||||
func WithContextFields(ctx context.Context, fields *ContextFields) context.Context {
|
||||
return context.WithValue(ctx, contextFieldsKey{}, fields)
|
||||
}
|
||||
|
||||
func ContextFieldsFrom(ctx context.Context) *ContextFields {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
fields, _ := ctx.Value(contextFieldsKey{}).(*ContextFields)
|
||||
return fields
|
||||
}
|
||||
|
||||
type contextHandler struct{ handler slog.Handler }
|
||||
|
||||
func (h *contextHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return h.handler.Enabled(ctx, level)
|
||||
}
|
||||
|
||||
func (h *contextHandler) Handle(ctx context.Context, record slog.Record) error {
|
||||
if fields := ContextFieldsFrom(ctx); fields != nil {
|
||||
record.AddAttrs(
|
||||
slog.String("request_id", fields.RequestID),
|
||||
slog.String("trace_id", fields.TraceID),
|
||||
slog.String("device_id", fields.DeviceID),
|
||||
slog.String("client_ip", fields.ClientIP),
|
||||
slog.String("http_method", fields.HTTPMethod),
|
||||
slog.String("http_path", fields.HTTPPath),
|
||||
)
|
||||
if fields.SpanID != "" {
|
||||
record.AddAttrs(slog.String("span_id", fields.SpanID))
|
||||
}
|
||||
if fields.ParentSpanID != "" {
|
||||
record.AddAttrs(slog.String("parent_span_id", fields.ParentSpanID))
|
||||
}
|
||||
}
|
||||
return h.handler.Handle(ctx, record)
|
||||
}
|
||||
|
||||
func (h *contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return &contextHandler{handler: h.handler.WithAttrs(attrs)}
|
||||
}
|
||||
|
||||
func (h *contextHandler) WithGroup(name string) slog.Handler {
|
||||
return &contextHandler{handler: h.handler.WithGroup(name)}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DailyWriter writes log entries to <root>/<yyyy-mm-dd>/<name>. It keeps the
|
||||
// file handle open for the active day and rotates on the first write after
|
||||
// midnight, matching the directory layout consumed by the log viewer.
|
||||
type DailyWriter struct {
|
||||
mu sync.Mutex
|
||||
root string
|
||||
name string
|
||||
date string
|
||||
file *os.File
|
||||
retentionDay int
|
||||
}
|
||||
|
||||
func NewDailyWriter(root, name string, retentionDay int) *DailyWriter {
|
||||
w := &DailyWriter{root: root, name: name, retentionDay: retentionDay}
|
||||
w.removeExpired()
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *DailyWriter) removeExpired() {
|
||||
if w.retentionDay <= 0 {
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(w.root)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().AddDate(0, 0, -w.retentionDay)
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
date, err := time.Parse("2006-01-02", entry.Name())
|
||||
if err == nil && date.Before(cutoff) {
|
||||
_ = os.RemoveAll(filepath.Join(w.root, entry.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *DailyWriter) Write(value []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
date := time.Now().Format("2006-01-02")
|
||||
if w.file == nil || w.date != date {
|
||||
if w.file != nil {
|
||||
_ = w.file.Close()
|
||||
}
|
||||
filePath := filepath.Join(w.root, date, filepath.Clean(w.name))
|
||||
directory := filepath.Dir(filePath)
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
w.file, w.date = file, date
|
||||
}
|
||||
return w.file.Write(value)
|
||||
}
|
||||
|
||||
func (w *DailyWriter) Sync() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.file == nil {
|
||||
return nil
|
||||
}
|
||||
return w.file.Sync()
|
||||
}
|
||||
|
||||
func (w *DailyWriter) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.file == nil {
|
||||
return nil
|
||||
}
|
||||
err := w.file.Close()
|
||||
w.file = nil
|
||||
return err
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type stackFrame struct {
|
||||
File string
|
||||
Line int
|
||||
Func string
|
||||
}
|
||||
|
||||
var stackFileLinePattern = regexp.MustCompile(`\s*(.+\.go):(\d+)\s*$`)
|
||||
|
||||
func finalApplicationCaller(stack string) (stackFrame, bool) {
|
||||
if stack == "" {
|
||||
return stackFrame{}, false
|
||||
}
|
||||
functionName := ""
|
||||
for _, raw := range strings.Split(stack, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
matches := stackFileLinePattern.FindStringSubmatch(line)
|
||||
if matches == nil {
|
||||
functionName = line
|
||||
continue
|
||||
}
|
||||
lineNumber, _ := strconv.Atoi(matches[2])
|
||||
if skipStackFile(matches[1]) {
|
||||
functionName = ""
|
||||
continue
|
||||
}
|
||||
return stackFrame{File: matches[1], Line: lineNumber, Func: functionName}, true
|
||||
}
|
||||
return stackFrame{}, false
|
||||
}
|
||||
|
||||
func skipStackFile(filename string) bool {
|
||||
normalized := strings.ReplaceAll(filename, "\\", "/")
|
||||
for _, marker := range []string{
|
||||
"/go/pkg/mod/",
|
||||
"/go.uber.org/",
|
||||
"/gorm.io/",
|
||||
"/pkg/logging/",
|
||||
"/internal/transport/middleware/",
|
||||
"/internal/transport/router/",
|
||||
"/internal/server/handler/",
|
||||
"/internal/server/middleware/",
|
||||
"/internal/server/router/",
|
||||
"/internal/server/httpx/",
|
||||
"/internal/server/middleware_",
|
||||
"/internal/server/route_",
|
||||
} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.Contains(normalized, "/src/") &&
|
||||
(strings.Contains(normalized, "/go/go") || strings.Contains(normalized, "/go/src/"))
|
||||
}
|
||||
|
||||
func functionSourceAt(filename string, line int) (name, source string, startLine, endLine int, err error) {
|
||||
content, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return "", "", 0, 0, fmt.Errorf("read file failed: %w", err)
|
||||
}
|
||||
files := token.NewFileSet()
|
||||
parsed, err := parser.ParseFile(files, filename, content, parser.ParseComments)
|
||||
if err != nil {
|
||||
return "", "", 0, 0, fmt.Errorf("parse file failed: %w", err)
|
||||
}
|
||||
var target *ast.FuncDecl
|
||||
ast.Inspect(parsed, func(node ast.Node) bool {
|
||||
declaration, ok := node.(*ast.FuncDecl)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
start := files.Position(declaration.Pos()).Line
|
||||
end := files.Position(declaration.End()).Line
|
||||
if line >= start && line <= end {
|
||||
target, startLine, endLine = declaration, start, end
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if target == nil {
|
||||
return "", "", 0, 0, fmt.Errorf("no function encloses line %d in %s", line, filename)
|
||||
}
|
||||
start := files.Position(target.Pos()).Offset
|
||||
end := files.Position(target.End()).Offset
|
||||
if start < 0 || end > len(content) || start >= end {
|
||||
return "", "", 0, 0, fmt.Errorf("invalid offsets for function: start=%d end=%d len=%d", start, end, len(content))
|
||||
}
|
||||
return target.Name.Name, string(content[start:end]), startLine, endLine, nil
|
||||
}
|
||||
@@ -1,545 +0,0 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-kratos/kratos/contrib/otel/v3/tracing"
|
||||
kratoslog "github.com/go-kratos/kratos/v3/log"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/exp/zapslog"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Level, Format, EncodeLevel, Prefix, StacktraceKey string
|
||||
LogInConsole, ShowLine bool
|
||||
RetentionDay int
|
||||
FileOnlyModules []string
|
||||
}
|
||||
|
||||
// ErrorEntry is the storage-neutral representation of an Error-level log.
|
||||
// Keeping it in pkg/logging lets the log core report failures without taking a
|
||||
// dependency on the application service or persistence layers.
|
||||
type ErrorEntry struct {
|
||||
Form, Info, Level, RequestID, TraceID string
|
||||
}
|
||||
|
||||
// ErrorSink receives Error-level log entries. The sink must not log failures
|
||||
// through the same logger, otherwise a storage failure could recurse forever.
|
||||
type ErrorSink interface {
|
||||
RecordLogError(context.Context, ErrorEntry) error
|
||||
}
|
||||
|
||||
type ErrorSinkFunc func(context.Context, ErrorEntry) error
|
||||
|
||||
func (f ErrorSinkFunc) RecordLogError(ctx context.Context, entry ErrorEntry) error {
|
||||
return f(ctx, entry)
|
||||
}
|
||||
|
||||
type errorSinkState struct {
|
||||
mu sync.RWMutex
|
||||
sink ErrorSink
|
||||
}
|
||||
|
||||
func (s *errorSinkState) record(entry ErrorEntry) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.RLock()
|
||||
sink := s.sink
|
||||
s.mu.RUnlock()
|
||||
if sink != nil {
|
||||
_ = sink.RecordLogError(context.Background(), entry)
|
||||
}
|
||||
}
|
||||
|
||||
type handlerOperation struct {
|
||||
attrs []slog.Attr
|
||||
group string
|
||||
}
|
||||
|
||||
type reloadableHandlerState struct {
|
||||
mu sync.RWMutex
|
||||
handler slog.Handler
|
||||
cleanup func()
|
||||
}
|
||||
|
||||
type reloadableHandler struct {
|
||||
state *reloadableHandlerState
|
||||
ops []handlerOperation
|
||||
}
|
||||
|
||||
func (h *reloadableHandler) resolved() slog.Handler {
|
||||
current := h.state.handler
|
||||
for _, operation := range h.ops {
|
||||
if operation.group != "" {
|
||||
current = current.WithGroup(operation.group)
|
||||
} else {
|
||||
current = current.WithAttrs(operation.attrs)
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func (h *reloadableHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
h.state.mu.RLock()
|
||||
defer h.state.mu.RUnlock()
|
||||
return h.resolved().Enabled(ctx, level)
|
||||
}
|
||||
|
||||
func (h *reloadableHandler) Handle(ctx context.Context, record slog.Record) error {
|
||||
h.state.mu.RLock()
|
||||
defer h.state.mu.RUnlock()
|
||||
return h.resolved().Handle(ctx, record)
|
||||
}
|
||||
|
||||
func (h *reloadableHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
next := append([]handlerOperation(nil), h.ops...)
|
||||
next = append(next, handlerOperation{attrs: append([]slog.Attr(nil), attrs...)})
|
||||
return &reloadableHandler{state: h.state, ops: next}
|
||||
}
|
||||
|
||||
func (h *reloadableHandler) WithGroup(name string) slog.Handler {
|
||||
next := append([]handlerOperation(nil), h.ops...)
|
||||
next = append(next, handlerOperation{group: name})
|
||||
return &reloadableHandler{state: h.state, ops: next}
|
||||
}
|
||||
|
||||
type ReloadableLogger struct {
|
||||
state *reloadableHandlerState
|
||||
filename string
|
||||
errorSink *errorSinkState
|
||||
}
|
||||
|
||||
func (l *ReloadableLogger) Reload(root string, options Options) {
|
||||
handler, cleanup := newZapHandler(root, l.filename, options, l.errorSink)
|
||||
l.state.mu.Lock()
|
||||
previous := l.state.cleanup
|
||||
l.state.handler = handler
|
||||
l.state.cleanup = cleanup
|
||||
l.state.mu.Unlock()
|
||||
if previous != nil {
|
||||
previous()
|
||||
}
|
||||
}
|
||||
|
||||
// SetErrorSink changes the database/audit target while keeping the current
|
||||
// logger and its hot-reload state intact.
|
||||
func (l *ReloadableLogger) SetErrorSink(sink ErrorSink) {
|
||||
if l == nil || l.errorSink == nil {
|
||||
return
|
||||
}
|
||||
l.errorSink.mu.Lock()
|
||||
l.errorSink.sink = sink
|
||||
l.errorSink.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *ReloadableLogger) Close() {
|
||||
l.state.mu.Lock()
|
||||
cleanup := l.state.cleanup
|
||||
l.state.cleanup = nil
|
||||
l.state.mu.Unlock()
|
||||
if cleanup != nil {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
type consoleSummaryCore struct {
|
||||
level zapcore.LevelEnabler
|
||||
output zapcore.WriteSyncer
|
||||
prefix string
|
||||
fileOnly map[string]struct{}
|
||||
fields []zapcore.Field
|
||||
}
|
||||
|
||||
func (c *consoleSummaryCore) Enabled(level zapcore.Level) bool { return c.level.Enabled(level) }
|
||||
|
||||
func (c *consoleSummaryCore) With(fields []zapcore.Field) zapcore.Core {
|
||||
inherited := append([]zapcore.Field(nil), c.fields...)
|
||||
inherited = append(inherited, fields...)
|
||||
return &consoleSummaryCore{level: c.level, output: c.output, prefix: c.prefix, fileOnly: c.fileOnly, fields: inherited}
|
||||
}
|
||||
|
||||
func (c *consoleSummaryCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry {
|
||||
if c.Enabled(entry.Level) {
|
||||
return checked.AddCore(entry, c)
|
||||
}
|
||||
return checked
|
||||
}
|
||||
|
||||
func (c *consoleSummaryCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
|
||||
allFields := append([]zapcore.Field(nil), c.fields...)
|
||||
allFields = append(allFields, fields...)
|
||||
module := moduleField(allFields)
|
||||
if _, excluded := c.fileOnly[module]; excluded {
|
||||
return nil
|
||||
}
|
||||
_, err := c.output.Write([]byte(formatConsoleEntry(entry, module, allFields, c.prefix)))
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *consoleSummaryCore) Sync() error { return c.output.Sync() }
|
||||
|
||||
var hiddenConsoleFields = map[string]struct{}{
|
||||
"mod": {}, "service.id": {}, "service.name": {}, "service.version": {},
|
||||
"node": {}, "app_id": {}, "env": {}, "trace_id": {}, "span_id": {},
|
||||
}
|
||||
|
||||
func formatConsoleEntry(entry zapcore.Entry, module string, fields []zapcore.Field, prefix string) string {
|
||||
values := zapcore.NewMapObjectEncoder()
|
||||
for _, field := range fields {
|
||||
field.AddTo(values)
|
||||
}
|
||||
keys := make([]string, 0, len(values.Fields))
|
||||
for key, value := range values.Fields {
|
||||
if _, hidden := hiddenConsoleFields[key]; hidden || consoleValueEmpty(value) {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
caller := ""
|
||||
if entry.Caller.Defined {
|
||||
caller = filepath.Base(entry.Caller.File) + fmt.Sprintf(":%d", entry.Caller.Line)
|
||||
}
|
||||
if module == "" {
|
||||
module = "app"
|
||||
}
|
||||
parts := []string{
|
||||
prefix + entry.Time.Format("15:04:05.000"),
|
||||
consoleLevel(entry.Level),
|
||||
fmt.Sprintf("%-10s", module),
|
||||
}
|
||||
if caller != "" {
|
||||
parts = append(parts, fmt.Sprintf("%-24s", caller))
|
||||
}
|
||||
parts = append(parts, entry.Message)
|
||||
for _, key := range keys {
|
||||
parts = append(parts, key+"="+formatConsoleValue(values.Fields[key]))
|
||||
}
|
||||
return strings.Join(parts, " ") + "\n"
|
||||
}
|
||||
|
||||
func consoleLevel(level zapcore.Level) string {
|
||||
label := strings.ToUpper(level.String())
|
||||
color := "\x1b[36m"
|
||||
switch {
|
||||
case level >= zapcore.ErrorLevel:
|
||||
color = "\x1b[31m"
|
||||
case level == zapcore.WarnLevel:
|
||||
color = "\x1b[33m"
|
||||
case level == zapcore.InfoLevel:
|
||||
color = "\x1b[32m"
|
||||
}
|
||||
return color + fmt.Sprintf("%-5s", label) + "\x1b[0m"
|
||||
}
|
||||
|
||||
func consoleValueEmpty(value any) bool {
|
||||
return value == nil || value == ""
|
||||
}
|
||||
|
||||
func formatConsoleValue(value any) string {
|
||||
text := fmt.Sprint(value)
|
||||
text = strings.Join(strings.Fields(text), " ")
|
||||
if len(text) > 180 {
|
||||
text = text[:177] + "..."
|
||||
}
|
||||
if strings.ContainsAny(text, " \t\r\n") {
|
||||
return fmt.Sprintf("%q", text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func moduleField(fields []zapcore.Field) string {
|
||||
for _, field := range fields {
|
||||
if field.Key == "mod" {
|
||||
if field.String != "" {
|
||||
return field.String
|
||||
}
|
||||
if field.Interface != nil {
|
||||
return fmt.Sprint(field.Interface)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type routedFileState struct {
|
||||
mu sync.Mutex
|
||||
writers map[string]*DailyWriter
|
||||
}
|
||||
|
||||
type routedFileCore struct {
|
||||
base zapcore.Core
|
||||
encoder zapcore.Encoder
|
||||
level zapcore.LevelEnabler
|
||||
root string
|
||||
retentionDay int
|
||||
state *routedFileState
|
||||
fields []zapcore.Field
|
||||
errorSink *errorSinkState
|
||||
}
|
||||
|
||||
func (c *routedFileCore) Enabled(level zapcore.Level) bool { return c.level.Enabled(level) }
|
||||
|
||||
func (c *routedFileCore) With(fields []zapcore.Field) zapcore.Core {
|
||||
inherited := append([]zapcore.Field(nil), c.fields...)
|
||||
inherited = append(inherited, fields...)
|
||||
return &routedFileCore{base: c.base.With(fields), encoder: c.encoder, level: c.level, root: c.root, retentionDay: c.retentionDay, state: c.state, fields: inherited, errorSink: c.errorSink}
|
||||
}
|
||||
|
||||
func (c *routedFileCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry {
|
||||
if c.Enabled(entry.Level) {
|
||||
return checked.AddCore(entry, c)
|
||||
}
|
||||
return checked
|
||||
}
|
||||
|
||||
func (c *routedFileCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
|
||||
baseErr := c.base.Write(entry, fields)
|
||||
allFields := append([]zapcore.Field(nil), c.fields...)
|
||||
allFields = append(allFields, fields...)
|
||||
if entry.Level >= zapcore.ErrorLevel && !isGORMLoggerEntry(entry.Caller.File, allFields) {
|
||||
c.errorSink.record(errorEntryFromZap(entry, allFields))
|
||||
}
|
||||
paths := routedLogPaths(moduleField(allFields), entry.Level)
|
||||
if len(paths) == 0 {
|
||||
return baseErr
|
||||
}
|
||||
buffer, err := c.encoder.Clone().EncodeEntry(entry, allFields)
|
||||
if err != nil {
|
||||
if baseErr != nil {
|
||||
return baseErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer buffer.Free()
|
||||
for _, name := range paths {
|
||||
writer := c.writer(name)
|
||||
if _, writeErr := writer.Write(buffer.Bytes()); writeErr != nil && baseErr == nil {
|
||||
baseErr = writeErr
|
||||
}
|
||||
}
|
||||
return baseErr
|
||||
}
|
||||
|
||||
func isGORMLoggerEntry(filename string, fields []zapcore.Field) bool {
|
||||
for _, field := range fields {
|
||||
if field.Key == "gorm_logger" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
normalized := strings.ReplaceAll(filename, "\\", "/")
|
||||
return strings.HasSuffix(normalized, "/gorm_logger_writer.go") ||
|
||||
strings.HasSuffix(normalized, "/pkg/database/gormkit/logger.go")
|
||||
}
|
||||
|
||||
func errorEntryFromZap(entry zapcore.Entry, fields []zapcore.Field) ErrorEntry {
|
||||
requestID, traceID, errorText := "", "", ""
|
||||
for _, field := range fields {
|
||||
switch field.Key {
|
||||
case "request_id":
|
||||
if requestID == "" {
|
||||
requestID = zapFieldString(field)
|
||||
}
|
||||
case "trace_id":
|
||||
if traceID == "" {
|
||||
traceID = zapFieldString(field)
|
||||
}
|
||||
case "error", "err":
|
||||
if errorText == "" {
|
||||
errorText = zapFieldString(field)
|
||||
}
|
||||
}
|
||||
}
|
||||
info := entry.Message
|
||||
if errorText != "" {
|
||||
info += " | 错误: " + errorText
|
||||
}
|
||||
if entry.Caller.File != "" {
|
||||
info += fmt.Sprintf(" \n 源文件:%s:%d", entry.Caller.File, entry.Caller.Line)
|
||||
}
|
||||
if entry.Stack != "" {
|
||||
info += " \n 调用栈:" + entry.Stack
|
||||
if frame, ok := finalApplicationCaller(entry.Stack); ok {
|
||||
functionName, source, startLine, endLine, err := functionSourceAt(frame.File, frame.Line)
|
||||
if err == nil {
|
||||
info += fmt.Sprintf(" \n 最终调用方法:%s:%d (%s lines %d-%d)\n----- 产生日志的方法代码如下 -----\n%s", frame.File, frame.Line, functionName, startLine, endLine, source)
|
||||
} else {
|
||||
info += fmt.Sprintf(" \n 最终调用方法:%s:%d (%s) | extract_err=%v", frame.File, frame.Line, functionName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ErrorEntry{Form: "后端", Info: info, Level: entry.Level.String(), RequestID: requestID, TraceID: traceID}
|
||||
}
|
||||
|
||||
func zapFieldString(field zapcore.Field) string {
|
||||
if field.String != "" {
|
||||
return field.String
|
||||
}
|
||||
if field.Interface != nil {
|
||||
return fmt.Sprint(field.Interface)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *routedFileCore) Sync() error {
|
||||
result := c.base.Sync()
|
||||
c.state.mu.Lock()
|
||||
defer c.state.mu.Unlock()
|
||||
for _, writer := range c.state.writers {
|
||||
if err := writer.Sync(); err != nil && result == nil {
|
||||
result = err
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *routedFileCore) writer(name string) *DailyWriter {
|
||||
c.state.mu.Lock()
|
||||
defer c.state.mu.Unlock()
|
||||
writer := c.state.writers[name]
|
||||
if writer == nil {
|
||||
writer = NewDailyWriter(c.root, name, c.retentionDay)
|
||||
c.state.writers[name] = writer
|
||||
}
|
||||
return writer
|
||||
}
|
||||
|
||||
func (c *routedFileCore) Close() {
|
||||
c.state.mu.Lock()
|
||||
defer c.state.mu.Unlock()
|
||||
for name, writer := range c.state.writers {
|
||||
_ = writer.Close()
|
||||
delete(c.state.writers, name)
|
||||
}
|
||||
}
|
||||
|
||||
func routedLogPaths(module string, level zapcore.Level) []string {
|
||||
paths := make([]string, 0, 2)
|
||||
if module = safeModuleName(module); module != "" {
|
||||
switch module {
|
||||
case "http":
|
||||
paths = append(paths, filepath.Join("http", "access.log"))
|
||||
case "timedTask":
|
||||
paths = append(paths, filepath.Join("timedTask", "task.log"))
|
||||
case "error":
|
||||
paths = append(paths, filepath.Join("error", "error.log"))
|
||||
default:
|
||||
paths = append(paths, filepath.Join(module, "application.log"))
|
||||
}
|
||||
}
|
||||
if level >= zapcore.ErrorLevel {
|
||||
errorPath := filepath.Join("error", "error.log")
|
||||
if len(paths) == 0 || paths[len(paths)-1] != errorPath {
|
||||
paths = append(paths, errorPath)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func safeModuleName(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, value)
|
||||
}
|
||||
|
||||
// NewZapLogger adapts a Zap core to the slog logger used by Kratos v3.
|
||||
// The file layout remains compatible with the administration log viewer.
|
||||
func newZapHandler(root, filename string, options Options, errorSink *errorSinkState) (slog.Handler, func()) {
|
||||
file := NewDailyWriter(root, filename, options.RetentionDay)
|
||||
encoder := zap.NewProductionEncoderConfig()
|
||||
encoder.EncodeTime = zapcore.RFC3339NanoTimeEncoder
|
||||
if options.StacktraceKey != "" {
|
||||
encoder.StacktraceKey = options.StacktraceKey
|
||||
}
|
||||
switch options.EncodeLevel {
|
||||
case "CapitalLevelEncoder":
|
||||
encoder.EncodeLevel = zapcore.CapitalLevelEncoder
|
||||
case "CapitalColorLevelEncoder":
|
||||
encoder.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||
case "LowercaseColorLevelEncoder":
|
||||
encoder.EncodeLevel = zapcore.LowercaseColorLevelEncoder
|
||||
default:
|
||||
encoder.EncodeLevel = zapcore.LowercaseLevelEncoder
|
||||
}
|
||||
var outputEncoder zapcore.Encoder = zapcore.NewJSONEncoder(encoder)
|
||||
if options.Format != "" && options.Format != "json" {
|
||||
if options.Prefix != "" {
|
||||
encoder.EncodeTime = func(value time.Time, output zapcore.PrimitiveArrayEncoder) {
|
||||
output.AppendString(options.Prefix + value.Format("2006-01-02 15:04:05.000"))
|
||||
}
|
||||
}
|
||||
outputEncoder = zapcore.NewConsoleEncoder(encoder)
|
||||
}
|
||||
level := zap.DebugLevel
|
||||
// zapcore.Level.Set reports an error but does not make the intended
|
||||
// configuration handling obvious here. Parse the configured value directly
|
||||
// so debug/warn/error are actually applied after startup and hot reload.
|
||||
if value := strings.TrimSpace(strings.ToLower(options.Level)); value != "" {
|
||||
if err := level.UnmarshalText([]byte(value)); err != nil {
|
||||
level = zap.DebugLevel
|
||||
}
|
||||
}
|
||||
levelEnabler := zap.NewAtomicLevelAt(level)
|
||||
fileCore := zapcore.NewCore(
|
||||
outputEncoder.Clone(),
|
||||
zapcore.AddSync(file),
|
||||
levelEnabler,
|
||||
)
|
||||
core := zapcore.Core(fileCore)
|
||||
if options.LogInConsole {
|
||||
fileOnly := make(map[string]struct{}, len(options.FileOnlyModules))
|
||||
for _, module := range options.FileOnlyModules {
|
||||
fileOnly[module] = struct{}{}
|
||||
}
|
||||
consoleCore := &consoleSummaryCore{level: levelEnabler, output: zapcore.AddSync(os.Stdout), prefix: options.Prefix, fileOnly: fileOnly}
|
||||
core = zapcore.NewTee(fileCore, consoleCore)
|
||||
}
|
||||
routed := &routedFileCore{base: core, encoder: outputEncoder.Clone(), level: levelEnabler, root: root, retentionDay: options.RetentionDay, state: &routedFileState{writers: map[string]*DailyWriter{}}, errorSink: errorSink}
|
||||
zapLogger := zap.New(routed)
|
||||
handlerOptions := []zapslog.HandlerOption{zapslog.AddStacktraceAt(slog.LevelError)}
|
||||
if options.ShowLine {
|
||||
handlerOptions = append(handlerOptions, zapslog.WithCaller(true))
|
||||
}
|
||||
handler := zapslog.NewHandler(zapLogger.Core(), handlerOptions...)
|
||||
cleanup := func() {
|
||||
_ = zapLogger.Sync()
|
||||
routed.Close()
|
||||
_ = file.Close()
|
||||
}
|
||||
return handler, cleanup
|
||||
}
|
||||
|
||||
// NewReloadableZapLogger keeps the slog/Kratos adapter stable while replacing
|
||||
// the underlying Zap core when the runtime configuration changes.
|
||||
func NewReloadableZapLogger(root, filename string, options Options, attrs ...any) (*slog.Logger, *ReloadableLogger) {
|
||||
errorSink := &errorSinkState{}
|
||||
baseHandler, cleanup := newZapHandler(root, filename, options, errorSink)
|
||||
state := &reloadableHandlerState{handler: baseHandler, cleanup: cleanup}
|
||||
control := &ReloadableLogger{state: state, filename: filename, errorSink: errorSink}
|
||||
handler := &contextHandler{handler: &reloadableHandler{state: state}}
|
||||
logger := kratoslog.NewLogger(handler, kratoslog.WithExtractor(tracing.TraceAttrs)).With(attrs...)
|
||||
return logger, control
|
||||
}
|
||||
|
||||
func NewZapLogger(root, filename string, options Options, attrs ...any) (*slog.Logger, func()) {
|
||||
logger, control := NewReloadableZapLogger(root, filename, options, attrs...)
|
||||
return logger, control.Close
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
func TestZapHandlerHonorsConfiguredLevel(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "error", Format: "json"}, nil)
|
||||
defer cleanup()
|
||||
|
||||
if handler.Enabled(context.Background(), slog.LevelInfo) {
|
||||
t.Fatal("info should be disabled when level is error")
|
||||
}
|
||||
if !handler.Enabled(context.Background(), slog.LevelError) {
|
||||
t.Fatal("error should be enabled when level is error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZapHandlerUsesDebugFallbackForInvalidLevel(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "not-a-level", Format: "json"}, nil)
|
||||
defer cleanup()
|
||||
|
||||
if !handler.Enabled(context.Background(), slog.LevelDebug) {
|
||||
t.Fatal("invalid levels should use the reference debug fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZapHandlerRoutesHTTPAndErrorLogs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "info", Format: "json"}, nil)
|
||||
logger := slog.New(handler)
|
||||
logger.Info("request", "mod", "http", "request_id", "req-1")
|
||||
logger.Error("failed", "mod", "users")
|
||||
cleanup()
|
||||
|
||||
dateEntries, err := os.ReadDir(root)
|
||||
if err != nil || len(dateEntries) != 1 {
|
||||
t.Fatalf("expected one daily log directory, entries=%v err=%v", len(dateEntries), err)
|
||||
}
|
||||
date := dateEntries[0].Name()
|
||||
paths := []string{
|
||||
filepath.Join(root, date, "application.log"),
|
||||
filepath.Join(root, date, "http", "access.log"),
|
||||
filepath.Join(root, date, "users", "application.log"),
|
||||
filepath.Join(root, date, "error", "error.log"),
|
||||
}
|
||||
for _, path := range paths {
|
||||
if info, err := os.Stat(path); err != nil || info.Size() == 0 {
|
||||
t.Fatalf("expected non-empty routed log %s: info=%v err=%v", path, info, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleEncoderDoesNotChangeJSONFileEncoder(t *testing.T) {
|
||||
config := zap.NewProductionEncoderConfig()
|
||||
config.EncodeTime = zapcore.RFC3339NanoTimeEncoder
|
||||
fileEncoder := zapcore.NewJSONEncoder(config)
|
||||
entry := zapcore.Entry{Level: zapcore.InfoLevel, Time: time.Date(2026, 8, 21, 16, 30, 0, 0, time.Local), Message: "started"}
|
||||
buffer, err := fileEncoder.EncodeEntry(entry, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer buffer.Free()
|
||||
if !strings.HasPrefix(buffer.String(), "{") || !strings.Contains(buffer.String(), `"msg":"started"`) {
|
||||
t.Fatalf("file encoder is no longer JSON: %s", buffer.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleSummaryHidesInfrastructureNoise(t *testing.T) {
|
||||
entry := zapcore.Entry{
|
||||
Level: zapcore.InfoLevel,
|
||||
Time: time.Date(2026, 8, 21, 16, 50, 53, 145000000, time.Local),
|
||||
Message: "register swagger handler",
|
||||
Caller: zapcore.NewEntryCaller(0, `D:\workspace\kra\internal\server\swagger.go`, 55, true),
|
||||
}
|
||||
line := formatConsoleEntry(entry, "system", []zapcore.Field{
|
||||
zap.String("service.id", "DESKTOP"), zap.String("app_id", "kra"),
|
||||
zap.String("trace_id", ""), zap.String("path", "/swagger/*any"),
|
||||
}, "[kra] ")
|
||||
for _, want := range []string{"[kra] 16:50:53.145", "INFO", "system", "swagger.go:55", "register swagger handler", "path=/swagger/*any"} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Fatalf("console line %q does not contain %q", line, want)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"service.id", "DESKTOP", "app_id", "trace_id", `D:\workspace`} {
|
||||
if strings.Contains(line, unwanted) {
|
||||
t.Fatalf("console line still contains %q: %s", unwanted, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZapHandlerRecordsEveryErrorThroughSink(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var entries []ErrorEntry
|
||||
logger, control := NewReloadableZapLogger(root, "application.log", Options{Level: "info", Format: "json"})
|
||||
defer control.Close()
|
||||
control.SetErrorSink(ErrorSinkFunc(func(_ context.Context, entry ErrorEntry) error {
|
||||
entries = append(entries, entry)
|
||||
return nil
|
||||
}))
|
||||
logger.Error("task execution failed", "mod", "timedTask", "request_id", "request-1", "trace_id", "trace-1", "error", os.ErrPermission)
|
||||
control.Reload(t.TempDir(), Options{Level: "info", Format: "json"})
|
||||
logger.Error("reloaded logger failure", "mod", "system")
|
||||
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("expected two error entries across reload, got %d", len(entries))
|
||||
}
|
||||
entry := entries[0]
|
||||
if entry.Form != "后端" || entry.Level != "error" || entry.RequestID != "request-1" || entry.TraceID != "trace-1" || !strings.Contains(entry.Info, "task execution failed") || !strings.Contains(entry.Info, os.ErrPermission.Error()) {
|
||||
t.Fatalf("unexpected error entry: %+v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorEntryIncludesFinalApplicationSource(t *testing.T) {
|
||||
filename := filepath.Join(t.TempDir(), "worker.go")
|
||||
content := "package sample\n\nfunc execute() {\n\tprintln(\"failed\")\n}\n"
|
||||
if err := os.WriteFile(filename, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := errorEntryFromZap(zapcore.Entry{Message: "task failed", Level: zapcore.ErrorLevel, Stack: "kra/internal/worker.execute\n" + filename + ":4"}, nil)
|
||||
if !strings.Contains(entry.Info, "最终调用方法:"+filename+":4 (execute lines 3-5)") || !strings.Contains(entry.Info, "func execute()") {
|
||||
t.Fatalf("expected final caller source in error entry: %s", entry.Info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipStackFileRecognizesServerTransportPackages(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{name: "handler", path: `D:\workspace\app\system\internal\server\handler\user.go`, want: true},
|
||||
{name: "middleware", path: `D:\workspace\app\system\internal\server\middleware\auth.go`, want: true},
|
||||
{name: "router", path: `/workspace/internal/server/router/user.go`, want: true},
|
||||
{name: "http helper", path: `/workspace/internal/server/httpx/response.go`, want: true},
|
||||
{name: "legacy flattened middleware", path: `D:\workspace\app\system\internal\server\middleware_auth.go`, want: true},
|
||||
{name: "legacy flattened route", path: `/workspace/internal/server/route_user.go`, want: true},
|
||||
{name: "handler remains application boundary", path: `/workspace/internal/server/handler_user.go`, want: false},
|
||||
{name: "legacy middleware", path: `/workspace/internal/transport/middleware/auth.go`, want: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := skipStackFile(test.path); got != test.want {
|
||||
t.Fatalf("skipStackFile(%q) = %v, want %v", test.path, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorSinkSkipsGORMBridge(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var entries []ErrorEntry
|
||||
state := &errorSinkState{sink: ErrorSinkFunc(func(_ context.Context, entry ErrorEntry) error {
|
||||
entries = append(entries, entry)
|
||||
return nil
|
||||
})}
|
||||
base := zapcore.NewNopCore()
|
||||
core := &routedFileCore{base: base, encoder: zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()), level: zapcore.ErrorLevel, root: root, state: &routedFileState{writers: map[string]*DailyWriter{}}, errorSink: state}
|
||||
defer core.Close()
|
||||
for _, filename := range []string{"/tmp/gorm_logger_writer.go", "/workspace/pkg/database/gormkit/logger.go"} {
|
||||
if err := core.Write(zapcore.Entry{Level: zapcore.ErrorLevel, Message: "database failed", Caller: zapcore.EntryCaller{Defined: true, File: filename, Line: 10}}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := core.Write(zapcore.Entry{Level: zapcore.ErrorLevel, Message: "database failed"}, []zapcore.Field{zap.Bool("gorm_logger", true)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("gorm bridge error must not recurse into sys_error: %+v", entries)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
# Payment Utilities
|
||||
|
||||
Stateless payment protocol helpers live here: amount conversion, status
|
||||
normalization, JSON/XML extraction, and request signing. Provider adapters and
|
||||
their SDK clients remain under `internal/integration/payment`.
|
||||
@@ -1,97 +0,0 @@
|
||||
package paymentutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseIntegerAmount parses an integer amount expressed in the smallest
|
||||
// currency unit. Floating point is intentionally not involved.
|
||||
func ParseIntegerAmount(value string) (int64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, errors.New("支付金额为空")
|
||||
}
|
||||
return strconv.ParseInt(value, 10, 64)
|
||||
}
|
||||
|
||||
// ParseDecimalAmount converts a major-unit decimal string to an integer amount
|
||||
// using a power-of-ten scale, without using floating point.
|
||||
func ParseDecimalAmount(value string, scale int64) (int64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || scale <= 0 {
|
||||
return 0, errors.New("支付金额或换算比例无效")
|
||||
}
|
||||
if strings.HasPrefix(value, "-") {
|
||||
return 0, errors.New("支付金额不能为负数")
|
||||
}
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) > 2 || parts[0] == "" {
|
||||
return 0, fmt.Errorf("支付金额格式错误: %s", value)
|
||||
}
|
||||
digits, err := scaleDigits(scale)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
whole, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
fraction := ""
|
||||
if len(parts) == 2 {
|
||||
fraction = parts[1]
|
||||
}
|
||||
if len(fraction) > digits {
|
||||
return 0, fmt.Errorf("支付金额精度超过 %d 位", digits)
|
||||
}
|
||||
fraction += strings.Repeat("0", digits-len(fraction))
|
||||
fractionValue := int64(0)
|
||||
if fraction != "" {
|
||||
fractionValue, err = strconv.ParseInt(fraction, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if whole > (int64(^uint64(0)>>1)-fractionValue)/scale {
|
||||
return 0, errors.New("支付金额超出范围")
|
||||
}
|
||||
return whole*scale + fractionValue, nil
|
||||
}
|
||||
|
||||
func FormatDecimalAmount(amount, scale int64) (string, error) {
|
||||
if amount <= 0 || scale <= 0 {
|
||||
return "", errors.New("支付金额或换算比例无效")
|
||||
}
|
||||
digits, err := scaleDigits(scale)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if digits == 0 {
|
||||
return strconv.FormatInt(amount, 10), nil
|
||||
}
|
||||
return fmt.Sprintf("%d.%0*d", amount/scale, digits, amount%scale), nil
|
||||
}
|
||||
|
||||
func scaleDigits(scale int64) (int, error) {
|
||||
digits := 0
|
||||
for n := scale; n > 1 && n%10 == 0; n /= 10 {
|
||||
digits++
|
||||
}
|
||||
if scale <= 0 || scale != pow10(digits) {
|
||||
return 0, errors.New("支付金额换算比例必须是 10 的幂")
|
||||
}
|
||||
return digits, nil
|
||||
}
|
||||
|
||||
func pow10(digits int) int64 {
|
||||
value := int64(1)
|
||||
for i := 0; i < digits; i++ {
|
||||
if value > (int64(^uint64(0)>>1))/10 {
|
||||
return 0
|
||||
}
|
||||
value *= 10
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package paymentutil
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDecimalAmountUsesIntegerMath(t *testing.T) {
|
||||
got, err := ParseDecimalAmount("12.34", 100)
|
||||
if err != nil || got != 1234 {
|
||||
t.Fatalf("ParseDecimalAmount = %d, %v", got, err)
|
||||
}
|
||||
formatted, err := FormatDecimalAmount(got, 100)
|
||||
if err != nil || formatted != "12.34" {
|
||||
t.Fatalf("FormatDecimalAmount = %q, %v", formatted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecimalAmountRejectsUnsafeInput(t *testing.T) {
|
||||
for _, value := range []string{"-1.00", "1.001", "1.2.3"} {
|
||||
if _, err := ParseDecimalAmount(value, 100); err == nil {
|
||||
t.Fatalf("ParseDecimalAmount(%q) accepted unsafe input", value)
|
||||
}
|
||||
}
|
||||
if _, err := ParseDecimalAmount("1", 3); err == nil {
|
||||
t.Fatal("non power-of-ten scale accepted")
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package paymentutil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CallbackAck is the provider-facing HTTP acknowledgement returned after a
|
||||
// payment notification is processed.
|
||||
type CallbackAck struct {
|
||||
StatusCode int
|
||||
ContentType string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// CallbackError carries a safe provider acknowledgement alongside the cause
|
||||
// that should be logged or returned to the application layer.
|
||||
type CallbackError struct {
|
||||
Cause error
|
||||
Ack CallbackAck
|
||||
}
|
||||
|
||||
func (e *CallbackError) Error() string {
|
||||
if e == nil || e.Cause == nil {
|
||||
return "支付回调处理失败"
|
||||
}
|
||||
return e.Cause.Error()
|
||||
}
|
||||
|
||||
func (e *CallbackError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
func CallbackFailure(err error, fallback CallbackAck) CallbackAck {
|
||||
var callbackErr *CallbackError
|
||||
if errors.As(err, &callbackErr) && callbackErr.Ack.StatusCode != 0 {
|
||||
return callbackErr.Ack
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// DefaultCallbackAck returns the conventional acknowledgement for providers
|
||||
// whose callback wire formats are stable across modules.
|
||||
func DefaultCallbackAck(provider string, success bool) CallbackAck {
|
||||
status := 200
|
||||
if !success {
|
||||
status = 500
|
||||
}
|
||||
switch provider {
|
||||
case ProviderAlipay, ProviderAlipayV3:
|
||||
body := "success"
|
||||
if !success {
|
||||
body = "failure"
|
||||
}
|
||||
return CallbackAck{StatusCode: status, ContentType: "text/plain; charset=utf-8", Body: []byte(body)}
|
||||
case ProviderWechatV2:
|
||||
code, message := "SUCCESS", "OK"
|
||||
if !success {
|
||||
code, message = "FAIL", "FAIL"
|
||||
}
|
||||
body := fmt.Sprintf("<xml><return_code><![CDATA[%s]]></return_code><return_msg><![CDATA[%s]]></return_msg></xml>", code, message)
|
||||
return CallbackAck{StatusCode: status, ContentType: "application/xml; charset=utf-8", Body: []byte(body)}
|
||||
case ProviderWechatV3:
|
||||
code, message := "SUCCESS", "成功"
|
||||
if !success {
|
||||
code, message = "FAIL", "失败"
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"code": code, "message": message})
|
||||
return CallbackAck{StatusCode: status, ContentType: "application/json; charset=utf-8", Body: body}
|
||||
case ProviderApple:
|
||||
return CallbackAck{StatusCode: status}
|
||||
default:
|
||||
body := "success"
|
||||
if !success {
|
||||
body = "failure"
|
||||
}
|
||||
return CallbackAck{StatusCode: status, ContentType: "text/plain; charset=utf-8", Body: []byte(body)}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package paymentutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultCallbackAck(t *testing.T) {
|
||||
cases := []struct {
|
||||
provider string
|
||||
success bool
|
||||
status int
|
||||
contentType string
|
||||
body string
|
||||
}{
|
||||
{ProviderAlipay, true, 200, "text/plain; charset=utf-8", "success"},
|
||||
{ProviderWechatV2, false, 500, "application/xml; charset=utf-8", "<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[FAIL]]></return_msg></xml>"},
|
||||
{ProviderWechatV3, true, 200, "application/json; charset=utf-8", `{"code":"SUCCESS","message":"成功"}`},
|
||||
{ProviderApple, true, 200, "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
ack := DefaultCallbackAck(tc.provider, tc.success)
|
||||
if ack.StatusCode != tc.status || ack.ContentType != tc.contentType || string(ack.Body) != tc.body {
|
||||
t.Fatalf("%s ack = %#v", tc.provider, ack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackFailureUsesProviderAck(t *testing.T) {
|
||||
fallback := DefaultCallbackAck(ProviderAlipay, false)
|
||||
want := DefaultCallbackAck(ProviderWechatV3, false)
|
||||
err := &CallbackError{Cause: errors.New("provider rejected"), Ack: want}
|
||||
got := CallbackFailure(err, fallback)
|
||||
if got.StatusCode != want.StatusCode || string(got.Body) != string(want.Body) {
|
||||
t.Fatalf("ack = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package paymentutil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func JSONObject(raw []byte) map[string]any {
|
||||
var value any
|
||||
if json.Unmarshal(raw, &value) != nil {
|
||||
return nil
|
||||
}
|
||||
object, _ := value.(map[string]any)
|
||||
return object
|
||||
}
|
||||
|
||||
func NestedString(value any, keys ...string) string {
|
||||
found := nestedValue(value, keys...)
|
||||
if found == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(found))
|
||||
}
|
||||
|
||||
func StringAtPath(value any, path string) string {
|
||||
found := ValueAtPath(value, path)
|
||||
if found == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(found))
|
||||
}
|
||||
|
||||
func ValueAtPath(value any, path string) any {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
current := value
|
||||
for _, part := range strings.Split(path, ".") {
|
||||
object, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
current = object[part]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func nestedValue(value any, keys ...string) any {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, key := range keys {
|
||||
if found, exists := object[key]; exists && found != nil && fmt.Sprint(found) != "" {
|
||||
return found
|
||||
}
|
||||
}
|
||||
for _, nestedKey := range []string{"data", "result", "order", "transaction", "response"} {
|
||||
if found := nestedValue(object[nestedKey], keys...); found != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package paymentutil
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNestedAndPathValues(t *testing.T) {
|
||||
object := JSONObject([]byte(`{"data":{"status":"SUCCESS","amount":100}}`))
|
||||
if got := NestedString(object, "status"); got != "SUCCESS" {
|
||||
t.Fatalf("NestedString = %q", got)
|
||||
}
|
||||
if got := StringAtPath(object, "data.amount"); got != "100" {
|
||||
t.Fatalf("StringAtPath = %q", got)
|
||||
}
|
||||
if got := StringAtPath(object, "data.missing"); got != "" {
|
||||
t.Fatalf("missing path = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package paymentutil
|
||||
|
||||
const (
|
||||
ProviderAlipay = "alipay"
|
||||
ProviderAlipayV3 = "alipay-v3"
|
||||
ProviderWechatV2 = "wechat-v2"
|
||||
ProviderWechatV3 = "wechat-v3"
|
||||
ProviderApple = "apple-iap"
|
||||
ProviderChinaums = "chinaums"
|
||||
ProviderSFT = "sft"
|
||||
ProviderSuperPay = "supper-pay"
|
||||
ProviderWechatGame = "wechat-game-pay"
|
||||
ProviderDouyinGame = "douyin-game-pay"
|
||||
ProviderDouyin = "douyin"
|
||||
ProviderQQ = "qq"
|
||||
ProviderAllinPay = "allinpay"
|
||||
ProviderLakala = "lakala"
|
||||
ProviderPayPal = "paypal"
|
||||
ProviderSaobei = "saobei"
|
||||
ProviderInternal = "internal"
|
||||
|
||||
ModeExternal = "external"
|
||||
ModeInternal = "internal"
|
||||
)
|
||||
|
||||
var externalProviders = [...]string{
|
||||
ProviderAlipay, ProviderAlipayV3, ProviderWechatV2, ProviderWechatV3, ProviderApple,
|
||||
ProviderChinaums, ProviderSFT, ProviderSuperPay, ProviderWechatGame, ProviderDouyinGame,
|
||||
ProviderDouyin, ProviderQQ, ProviderAllinPay, ProviderLakala, ProviderPayPal, ProviderSaobei,
|
||||
}
|
||||
|
||||
// SupportedProviders returns a copy so callers cannot mutate the process-wide
|
||||
// provider catalog.
|
||||
func SupportedProviders() []string {
|
||||
return append([]string(nil), externalProviders[:]...)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package paymentutil
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func MD5Canonical(values map[string]any, secret string) string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key, value := range values {
|
||||
if key != "sign" && value != nil && fmt.Sprint(value) != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
query := url.Values{}
|
||||
for _, key := range keys {
|
||||
query.Set(key, fmt.Sprint(values[key]))
|
||||
}
|
||||
sum := md5.Sum([]byte(query.Encode() + "&key=" + secret))
|
||||
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
func HMACSHA256Hex(raw []byte, secret string, upper bool) string {
|
||||
h := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = h.Write(raw)
|
||||
result := hex.EncodeToString(h.Sum(nil))
|
||||
if upper {
|
||||
return strings.ToUpper(result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package paymentutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NormalizeStatus(value, fallback string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||
case "SUCCESS", "OK", "PAID", "TRADE_SUCCESS", "TRADE_FINISHED", "COMPLETED", "PAY_SUCCESS", "REFUND_SUCCESS", "REFUNDED":
|
||||
return "success"
|
||||
case "WAIT_BUYER_PAY", "USERPAYING", "NOTPAY", "PROCESSING", "PENDING", "CREATED", "ACCEPT", "ACCEPTED", "PAYING":
|
||||
return "pending"
|
||||
case "CLOSED", "TRADE_CLOSED", "CANCELLED", "CANCELED", "REVOKED", "REFUND", "FAILED", "FAIL", "REJECTED", "DENIED", "DECLINED", "ERROR", "PAYERROR":
|
||||
return "failed"
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func ConfiguredInt64(values map[string]any, key string, fallback int64) int64 {
|
||||
value, ok := values[key]
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := ParseIntegerAmount(strings.TrimSpace(toString(value)))
|
||||
if err != nil || parsed <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func ConfiguredValues(values map[string]any, key string) []string {
|
||||
result := []string{}
|
||||
for _, item := range strings.Split(toString(values[key]), ",") {
|
||||
item = strings.ToUpper(strings.TrimSpace(item))
|
||||
if item != "" {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func ContainsFold(values []string, value string) bool {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
for _, item := range values {
|
||||
if item == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func Text(values map[string]any, key string) string {
|
||||
value, _ := values[key].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
func FirstText(values map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := Text(values, key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func toString(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.ReplaceAll(strings.TrimSpace(stringify(value)), "<nil>", ""))
|
||||
}
|
||||
|
||||
func stringify(value any) string {
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package paymentutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type xmlValue struct {
|
||||
XMLName xml.Name
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
func XMLValues(raw []byte) (map[string]string, error) {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(raw))
|
||||
result := map[string]string{}
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
return result, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start, ok := token.(xml.StartElement)
|
||||
if !ok || start.Name.Local == "xml" {
|
||||
continue
|
||||
}
|
||||
var value xmlValue
|
||||
if err = decoder.DecodeElement(&value, &start); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value.Value != "" {
|
||||
result[start.Name.Local] = value.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func XMLEncode(values map[string]string) []byte {
|
||||
var b strings.Builder
|
||||
b.WriteString("<xml>")
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
b.WriteString("<")
|
||||
b.WriteString(key)
|
||||
b.WriteString("><![CDATA[")
|
||||
b.WriteString(values[key])
|
||||
b.WriteString("]]></")
|
||||
b.WriteString(key)
|
||||
b.WriteString(">")
|
||||
}
|
||||
b.WriteString("</xml>")
|
||||
return []byte(b.String())
|
||||
}
|
||||
@@ -36,12 +36,6 @@ func (r *Registry) Register(method Method) {
|
||||
r.methods[method.Name] = method
|
||||
}
|
||||
|
||||
func (r *Registry) RegisterAll(methods []Method) {
|
||||
for _, method := range methods {
|
||||
r.Register(method)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) Lookup(name string) (MethodFunc, bool) {
|
||||
if r == nil {
|
||||
return nil, false
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
|
||||
func TestRegistryIsComposableAndIdempotent(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.RegisterAll([]Method{{Name: "orders.sync", Run: func(context.Context, json.RawMessage) error { return nil }}, {Name: "system.cleanup", Run: func(context.Context, json.RawMessage) error { return nil }}})
|
||||
for _, method := range []Method{{Name: "orders.sync", Run: func(context.Context, json.RawMessage) error { return nil }}, {Name: "system.cleanup", Run: func(context.Context, json.RawMessage) error { return nil }}} {
|
||||
registry.Register(method)
|
||||
}
|
||||
registry.Register(Method{Name: "orders.sync", Description: "updated", Run: func(context.Context, json.RawMessage) error { return nil }})
|
||||
methods := registry.List()
|
||||
if len(methods) != 2 || methods[0].Name != "orders.sync" || methods[0].Description != "updated" {
|
||||
|
||||
Reference in New Issue
Block a user