优化结构

This commit is contained in:
Yvan 2026-08-21 00:28:09 +08:00
parent 361a562f75
commit 13f4589a18
21 changed files with 32 additions and 506 deletions

View File

@ -1,6 +1,6 @@
package payment package payment
import "kra/app/system/internal/utils/paymentutil" import "kra/pkg/paymentkit"
func text(values map[string]any, key string) string { func text(values map[string]any, key string) string {
value, _ := values[key].(string) value, _ := values[key].(string)

View File

@ -1,6 +1,6 @@
# Integrations # Integrations
`internal/integration` contains adapters that talk to systems outside the `app/system/internal/integration` contains adapters that talk to systems outside the
application process. They may open sockets, create SDK clients, keep reloadable application process. They may open sockets, create SDK clients, keep reloadable
state, or translate provider-specific protocols into `biz` interfaces. state, or translate provider-specific protocols into `biz` interfaces.
@ -16,5 +16,5 @@ lifecycles. Their constructors are exposed through `ProviderSet`; `cmd` binds
the `cache.RedisProvider` implementation to the shared `data.Data` container. the `cache.RedisProvider` implementation to the shared `data.Data` container.
Stateless protocol helpers that do not own clients live in Stateless protocol helpers that do not own clients live in
`internal/utils/paymentutil`. They are intentionally small and dependency `pkg/paymentkit`. They are intentionally small and dependency
light, while provider adapters remain here. light, while provider adapters remain here.

View File

@ -21,7 +21,7 @@ import (
"testing" "testing"
"kra/app/system/internal/biz" "kra/app/system/internal/biz"
"kra/app/system/internal/utils/paymentutil" "kra/pkg/paymentkit"
gopayAlipay "github.com/go-pay/gopay/alipay" gopayAlipay "github.com/go-pay/gopay/alipay"
gopayDouyin "github.com/go-pay/gopay/douyin" gopayDouyin "github.com/go-pay/gopay/douyin"

View File

@ -12,7 +12,7 @@ import (
"testing" "testing"
"kra/app/system/internal/biz" "kra/app/system/internal/biz"
"kra/app/system/internal/utils/paymentutil" "kra/pkg/paymentkit"
"github.com/go-pay/gopay" "github.com/go-pay/gopay"
gopayQQ "github.com/go-pay/gopay/qq" gopayQQ "github.com/go-pay/gopay/qq"

View File

@ -8,7 +8,7 @@ import (
"strings" "strings"
"kra/app/system/internal/biz" "kra/app/system/internal/biz"
"kra/app/system/internal/utils/paymentutil" "kra/pkg/paymentkit"
) )
func callbackFields(callback *biz.PaymentCallback) map[string]string { func callbackFields(callback *biz.PaymentCallback) map[string]string {

View File

@ -15,7 +15,7 @@ import (
"time" "time"
"kra/app/system/internal/biz" "kra/app/system/internal/biz"
"kra/app/system/internal/utils/paymentutil" "kra/pkg/paymentkit"
) )
type vendorProfile string type vendorProfile string

View File

@ -1,40 +1,29 @@
package httpx package httpx
import ( import (
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
shared "kra/pkg/httpx"
) )
const ( const (
CodeSuccess = 0 CodeSuccess = shared.CodeSuccess
CodeError = 7 CodeError = shared.CodeError
CodePasswordChangeRequired = 10001 CodePasswordChangeRequired = shared.CodePasswordChangeRequired
) )
type Response struct { type Response = shared.Response
Code int `json:"code"` type PageResult = shared.PageResult
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) { func Write(c *gin.Context, code int, data any, message string) {
c.JSON(http.StatusOK, Response{Code: code, Data: data, Msg: message}) shared.Write(c, code, data, message)
} }
func OK(c *gin.Context) { Write(c, CodeSuccess, gin.H{}, "操作成功") } func OK(c *gin.Context) { shared.OK(c) }
func OKWithData(c *gin.Context, data any) { Write(c, CodeSuccess, data, "成功") } func OKWithData(c *gin.Context, data any) { shared.OKWithData(c, data) }
func Fail(c *gin.Context, message string) { Write(c, CodeError, gin.H{}, message) } func Fail(c *gin.Context, message string) { shared.Fail(c, message) }
func NoAuth(c *gin.Context, message string) { func NoAuth(c *gin.Context, message string) {
c.AbortWithStatusJSON(http.StatusUnauthorized, Response{Code: CodeError, Data: nil, Msg: message}) shared.NoAuth(c, message)
} }

View File

@ -2,7 +2,8 @@ package configutil
import ( import (
"encoding/json" "encoding/json"
"strings"
"kra/pkg/protoutil"
"google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
@ -11,68 +12,5 @@ import (
// MergeProtoJSON applies a JSON object as a partial protobuf update while // MergeProtoJSON applies a JSON object as a partial protobuf update while
// retaining fields omitted by the caller. // retaining fields omitted by the caller.
func MergeProtoJSON(target proto.Message, patch json.RawMessage, options protojson.UnmarshalOptions) error { func MergeProtoJSON(target proto.Message, patch json.RawMessage, options protojson.UnmarshalOptions) error {
currentRaw, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(target) return protoutil.MergeProtoJSON(target, patch, options)
if err != nil {
return err
}
var current map[string]any
if err = json.Unmarshal(currentRaw, &current); err != nil {
return err
}
var incoming map[string]any
if err = json.Unmarshal(patch, &incoming); err != nil {
return err
}
incoming = normalizeJSONKeys(incoming).(map[string]any)
mergeJSONObjects(current, incoming)
merged, err := json.Marshal(current)
if err != nil {
return err
}
return options.Unmarshal(merged, target)
}
func normalizeJSONKeys(value any) any {
switch item := value.(type) {
case map[string]any:
result := make(map[string]any, len(item))
for key, nested := range item {
result[snakeToLowerCamel(key)] = normalizeJSONKeys(nested)
}
return result
case []any:
result := make([]any, len(item))
for index, nested := range item {
result[index] = normalizeJSONKeys(nested)
}
return result
default:
return value
}
}
func snakeToLowerCamel(value string) string {
if !strings.Contains(value, "_") {
return value
}
parts := strings.Split(value, "_")
result := parts[0]
for _, part := range parts[1:] {
if part != "" {
result += strings.ToUpper(part[:1]) + part[1:]
}
}
return result
}
func mergeJSONObjects(target, patch map[string]any) {
for key, value := range patch {
if incoming, ok := value.(map[string]any); ok {
if existing, exists := target[key].(map[string]any); exists {
mergeJSONObjects(existing, incoming)
continue
}
}
target[key] = value
}
} }

View File

@ -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`.

View File

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

View File

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

View File

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

View File

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

View File

@ -1,58 +0,0 @@
package paymentutil
import (
"crypto/hmac"
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/url"
"sort"
"strings"
)
func WechatV2Sign(values map[string]string, key, signType string) string {
keys := make([]string, 0, len(values))
for k, v := range values {
if k != "sign" && v != "" {
keys = append(keys, k)
}
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+"="+values[k])
}
raw := strings.Join(parts, "&") + "&key=" + key
if strings.EqualFold(signType, "HMAC-SHA256") {
return HMACSHA256Hex([]byte(raw), key, true)
}
sum := md5.Sum([]byte(raw))
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
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
}

View File

@ -1,12 +0,0 @@
package paymentutil
import "testing"
func TestWechatV2SignIsStableAndExcludesSign(t *testing.T) {
values := map[string]string{"b": "2", "a": "1", "sign": "old"}
first := WechatV2Sign(values, "secret", "MD5")
values["sign"] = "different"
if second := WechatV2Sign(values, "secret", "MD5"); first != second {
t.Fatalf("signature changed when sign field changed: %q != %q", first, second)
}
}

View File

@ -1,63 +0,0 @@
package paymentutil
import (
"fmt"
"strings"
)
func NormalizeStatus(value, fallback string) string {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "SUCCESS", "PAID", "TRADE_SUCCESS", "TRADE_FINISHED", "COMPLETED", "PAY_SUCCESS":
return "success"
case "WAIT_BUYER_PAY", "USERPAYING", "NOTPAY", "PROCESSING", "PENDING", "CREATED", "ACCEPT", "PAYING":
return "pending"
case "CLOSED", "TRADE_CLOSED", "CANCELLED", "CANCELED", "REVOKED", "REFUND", "REFUNDED", "FAILED", "FAIL", "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 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)
}

View File

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

View File

@ -1,4 +1,4 @@
# Platform # Shared Packages
这里放跨业务模块的基础设施,不放具体业务表或业务用例。 这里放跨业务模块的基础设施,不放具体业务表或业务用例。
@ -7,7 +7,8 @@
- `database`:跨模块共享的 GORM 支持、分页和迁移执行器 - `database`:跨模块共享的 GORM 支持、分页和迁移执行器
- `module`:模块迁移、后台元数据和路由注册协议 - `module`:模块迁移、后台元数据和路由注册协议
- `task`:跨模块共享的进程内任务注册表和贡献协议 - `task`:跨模块共享的进程内任务注册表和贡献协议
- `logging`:跨 app 复用的结构化日志能力
- `paymentkit`:支付金额、签名、状态和报文处理纯函数
平台层只能提供机制,不能引用 `internal/modules/*`。数据库配置加载、系统集成配置 `pkg` 只能提供机制和稳定协议,不能引用任何 `app/*/internal`。数据库配置加载、
和系统表仍由 system 模块负责;未来提炼公共连接管理时,也必须先形成不带系统 系统集成配置、系统表和 provider 生命周期仍由 `app/system` 负责。
业务语义的稳定接口,再移动到这里。

View File

@ -50,9 +50,9 @@ func skipStackFile(filename string) bool {
"/go/pkg/mod/", "/go/pkg/mod/",
"/go.uber.org/", "/go.uber.org/",
"/gorm.io/", "/gorm.io/",
"/internal/logging/", "/pkg/logging/",
"/internal/server/middleware/", "/internal/transport/middleware/",
"/internal/server/router/", "/internal/transport/router/",
} { } {
if strings.Contains(normalized, marker) { if strings.Contains(normalized, marker) {
return true return true

View File

@ -25,7 +25,7 @@ type Options struct {
} }
// ErrorEntry is the storage-neutral representation of an Error-level log. // ErrorEntry is the storage-neutral representation of an Error-level log.
// Keeping it in internal/logging lets the log core report failures without taking a // Keeping it in pkg/logging lets the log core report failures without taking a
// dependency on the application service or persistence layers. // dependency on the application service or persistence layers.
type ErrorEntry struct { type ErrorEntry struct {
Form, Info, Level, RequestID, TraceID string Form, Info, Level, RequestID, TraceID string
@ -264,7 +264,7 @@ func isGORMLoggerEntry(filename string, fields []zapcore.Field) bool {
} }
normalized := strings.ReplaceAll(filename, "\\", "/") normalized := strings.ReplaceAll(filename, "\\", "/")
return strings.HasSuffix(normalized, "/gorm_logger_writer.go") || return strings.HasSuffix(normalized, "/gorm_logger_writer.go") ||
strings.HasSuffix(normalized, "/internal/data/gorm_logger.go") strings.HasSuffix(normalized, "/pkg/database/gormkit/logger.go")
} }
func errorEntryFromZap(entry zapcore.Entry, fields []zapcore.Field) ErrorEntry { func errorEntryFromZap(entry zapcore.Entry, fields []zapcore.Field) ErrorEntry {

View File

@ -105,7 +105,7 @@ func TestErrorSinkSkipsGORMBridge(t *testing.T) {
base := zapcore.NewNopCore() 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} 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() defer core.Close()
for _, filename := range []string{"/tmp/gorm_logger_writer.go", "/workspace/internal/data/gorm_logger.go"} { 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 { 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) t.Fatal(err)
} }