Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -1808,6 +1808,10 @@ const docTemplate = `{
"type": "string",
"maxLength": 100
},
"public_key": {
"type": "string",
"maxLength": 100
},
"redirect_uri": {
"type": "string",
"maxLength": 100
Expand Down Expand Up @@ -1836,6 +1840,10 @@ const docTemplate = `{
"type": "string",
"maxLength": 100
},
"public_key": {
"type": "string",
"maxLength": 100
},
"redirect_uri": {
"type": "string",
"maxLength": 100
Expand Down
8 changes: 8 additions & 0 deletions docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -1799,6 +1799,10 @@
"type": "string",
"maxLength": 100
},
"public_key": {
"type": "string",
"maxLength": 100
},
"redirect_uri": {
"type": "string",
"maxLength": 100
Expand Down Expand Up @@ -1827,6 +1831,10 @@
"type": "string",
"maxLength": 100
},
"public_key": {
"type": "string",
"maxLength": 100
},
"redirect_uri": {
"type": "string",
"maxLength": 100
Expand Down
6 changes: 6 additions & 0 deletions docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ definitions:
notify_url:
maxLength: 100
type: string
public_key:
maxLength: 100
type: string
redirect_uri:
maxLength: 100
type: string
Expand All @@ -37,6 +40,9 @@ definitions:
notify_url:
maxLength: 100
type: string
public_key:
maxLength: 100
type: string
redirect_uri:
maxLength: 100
type: string
Expand Down
201 changes: 182 additions & 19 deletions frontend/components/common/docs/api.tsx

Large diffs are not rendered by default.

18 changes: 16 additions & 2 deletions frontend/components/common/merchant/merchant-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export function MerchantDialog({
app_description: '',
redirect_uri: '',
notify_url: '',
public_key: '',
test_mode: false,
})

Expand All @@ -71,6 +72,7 @@ export function MerchantDialog({
app_description: apiKey.app_description,
redirect_uri: apiKey.redirect_uri,
notify_url: apiKey.notify_url,
public_key: apiKey.public_key,
test_mode: apiKey.test_mode,
}
}
Expand Down Expand Up @@ -177,8 +179,8 @@ export function MerchantDialog({

setOpen(false)
} catch (error) {
const errorMessage = (error as Error).message || `无法${ mode === 'create' ? '创建' : '更新' }应用`
toast.error(`${ mode === 'create' ? '创建' : '更新' }失败`, {
const errorMessage = (error as Error).message || `无法${mode === 'create' ? '创建' : '更新'}应用`
toast.error(`${mode === 'create' ? '创建' : '更新'}失败`, {
description: errorMessage
})
throw error
Expand Down Expand Up @@ -329,6 +331,18 @@ export function MerchantDialog({
disabled={processing}
/>
</div>

<div className="grid gap-2">
<Label htmlFor="public_key">Ed25519 公钥 (Base64)</Label>
<Input
id="public_key"
placeholder="可选,用于请求签名验证,必须为 32 字节的 Ed25519 公钥(Base64 编码)"
maxLength={100}
value={formData.public_key || ''}
onChange={(e) => setFormData({ ...formData, public_key: e.target.value })}
disabled={processing}
/>
</div>
</div>

<DialogFooter>
Expand Down
6 changes: 6 additions & 0 deletions frontend/lib/services/merchant/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface MerchantAPIKey {
redirect_uri?: string;
/** 通知 URL */
notify_url: string;
/** 公钥 (Base64) */
public_key?: string;
/** 测试模式 */
test_mode: boolean;
/** 创建时间 */
Expand All @@ -44,6 +46,8 @@ export interface CreateAPIKeyRequest {
redirect_uri?: string;
/** 通知 URL(最大100字符,必须是有效的 URL) */
notify_url: string;
/** 公钥 (Base64 编码,32字节,可选) */
public_key?: string;
/** 测试模式(可选,默认为 false) */
test_mode?: boolean;
}
Expand All @@ -62,6 +66,8 @@ export interface UpdateAPIKeyRequest {
redirect_uri?: string;
/** 通知 URL(最大100字符,必须是有效的 URL,可选) */
notify_url?: string;
/** 公钥 (Base64 编码,32字节,可选) */
public_key?: string;
/** 测试模式(可选) */
test_mode?: boolean;
}
Expand Down
29 changes: 29 additions & 0 deletions internal/apps/merchant/api_key/routers.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/merchant"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/apps/payment"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
Expand All @@ -33,6 +34,7 @@ type CreateAPIKeyRequest struct {
AppDescription string `json:"app_description" binding:"max=100"`
RedirectURI string `json:"redirect_uri" binding:"omitempty,max=100,url"`
NotifyURL string `json:"notify_url" binding:"required,max=100,url"`
PublicKey string `json:"public_key" binding:"omitempty,max=100"`
TestMode bool `json:"test_mode"`
}

Expand All @@ -42,6 +44,7 @@ type UpdateAPIKeyRequest struct {
AppDescription string `json:"app_description" binding:"omitempty,max=100"`
RedirectURI string `json:"redirect_uri" binding:"omitempty,max=100,url"`
NotifyURL string `json:"notify_url" binding:"omitempty,max=100,url"`
PublicKey string `json:"public_key" binding:"omitempty,max=100"`
TestMode bool `json:"test_mode"`
}

Expand Down Expand Up @@ -78,6 +81,19 @@ func CreateAPIKey(c *gin.Context) {
TestMode: req.TestMode,
}

if len(req.PublicKey) > 0 {
publicKeyBytes, err := util.Base64Decode(req.PublicKey)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(payment.InvalidPublicKeyFormat))
return
}
if len(publicKeyBytes) != 32 {
c.JSON(http.StatusBadRequest, util.Err(payment.InvalidPublicKeyLength))
return
}
apiKey.PublicKey = publicKeyBytes
}

if err := db.DB(c.Request.Context()).Create(&apiKey).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
Expand Down Expand Up @@ -143,6 +159,19 @@ func UpdateAPIKey(c *gin.Context) {
"test_mode": req.TestMode,
}

if len(req.PublicKey) > 0 {
publicKeyBytes, err := util.Base64Decode(req.PublicKey)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(payment.InvalidPublicKeyFormat))
return
}
if len(publicKeyBytes) != 32 {
c.JSON(http.StatusBadRequest, util.Err(payment.InvalidPublicKeyLength))
return
}
updates["public_key"] = publicKeyBytes
}

if err := db.DB(c.Request.Context()).
Model(&apiKey).
Updates(updates).Error; err != nil {
Expand Down
18 changes: 10 additions & 8 deletions internal/apps/payment/errs.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ limitations under the License.
package payment

const (
OrderNotFound = "订单不存在或已完成"
OrderStatusInvalid = "订单状态不允许支付"
OrderExpired = "订单已过期"
MerchantInfoNotFound = "商户信息不存在"
RecipientNotFound = "收款人不存在"
OrderNoFormatError = "订单号格式错误"
CannotTransferToSelf = "不能转账给自己"
PayConfigNotFound = "支付配置不存在"
OrderNotFound = "订单不存在或已完成"
OrderStatusInvalid = "订单状态不允许支付"
OrderExpired = "订单已过期"
MerchantInfoNotFound = "商户信息不存在"
RecipientNotFound = "收款人不存在"
OrderNoFormatError = "订单号格式错误"
CannotTransferToSelf = "不能转账给自己"
PayConfigNotFound = "支付配置不存在"
InvalidPublicKeyFormat = "公钥格式错误"
InvalidPublicKeyLength = "公钥长度必须为32字节"
)
36 changes: 27 additions & 9 deletions internal/apps/payment/middlewares.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,23 @@ type EPayRequest struct {
SignType string `form:"sign_type"`
}

// ToCreateOrderRequest 转换为通用创建订单请求
func (r *EPayRequest) ToCreateOrderRequest() *CreateOrderRequest {
// LDCPayRequest LDC支付请求
type LDCPayRequest struct {
ClientID string `form:"client_id" binding:"required"`
OrderName string `form:"order_name" binding:"required,max=64"`
MerchantOrderNo *string `form:"out_trade_no" binding:"required,min=1,max=64"`
Amount decimal.Decimal `form:"money" binding:"required"`
PayType string `form:"type" binding:"required"`
Sign string `form:"sign" binding:"required"`
}

// NewCreateOrderRequest 从支付请求创建通用订单请求
func NewCreateOrderRequest(orderName string, merchantOrderNo *string, amount decimal.Decimal, payType string) *CreateOrderRequest {
return &CreateOrderRequest{
OrderName: r.OrderName,
MerchantOrderNo: r.MerchantOrderNo,
Amount: r.Amount,
PaymentType: r.PayType,
OrderName: orderName,
MerchantOrderNo: merchantOrderNo,
Amount: amount,
PaymentType: payType,
}
}

Expand Down Expand Up @@ -116,20 +126,28 @@ func RequireSignatureAuth() gin.HandlerFunc {
PayType := c.Request.FormValue("type")

var apiKey model.MerchantAPIKey
var createOrderReq *CreateOrderRequest
var err error

switch PayType {
case common.PayTypeLDCPay:
createOrderReq, err = VerifySignatureEd25519(c, &apiKey)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err(err.Error()))
return
}
case common.PayTypeEPay:
if createOrderReq, err := VerifySignature(c, &apiKey); err != nil {
createOrderReq, err = VerifySignatureMD5(c, &apiKey)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err(err.Error()))
return
} else {
util.SetToContext(c, CreateOrderRequestKey, createOrderReq)
}
default:
c.AbortWithStatusJSON(http.StatusBadRequest, util.Err("不支持的请求类型"))
return
}

util.SetToContext(c, CreateOrderRequestKey, createOrderReq)
util.SetToContext(c, APIKeyObjKey, &apiKey)

c.Next()
Expand Down
3 changes: 1 addition & 2 deletions internal/apps/payment/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,9 @@ func HandleMerchantPaymentNotify(ctx context.Context, t *asynq.Task) error {
"name": order.OrderName,
"money": order.Amount.Truncate(2).StringFixed(2),
"trade_status": "TRADE_SUCCESS",
"sign_type": "MD5",
}

callbackParams["sign"] = GenerateSignature(callbackParams, apiKey.ClientSecret)
callbackParams["sign"] = GenerateSignature(callbackParams, apiKey.ClientSecret, true)

if err := sendCallbackRequest(ctx, apiKey.NotifyURL, callbackParams); err != nil {
retried, _ := asynq.GetRetryCount(ctx)
Expand Down
67 changes: 57 additions & 10 deletions internal/apps/payment/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,8 @@ func ParseOrderNo(c *gin.Context, orderNo string) (*OrderContext, error) {
return ctx, nil
}

// GenerateSignature 生成MD5签名
func GenerateSignature(params map[string]string, secret string) string {
// GenerateSignature 生成签名
func GenerateSignature(params map[string]string, secret string, isMD5 bool) string {
// 按key排序
keys := make([]string, 0, len(params))
for k := range params {
Expand Down Expand Up @@ -180,13 +180,16 @@ func GenerateSignature(params map[string]string, secret string) string {
}
builder.WriteString(secret)

// MD5加密
hash := md5.Sum([]byte(builder.String()))
return fmt.Sprintf("%x", hash)
if isMD5 {
// MD5加密
hash := md5.Sum([]byte(builder.String()))
return fmt.Sprintf("%x", hash)
}
return builder.String()
}

// VerifySignature 验证MD5签名
func VerifySignature(c *gin.Context, apiKey *model.MerchantAPIKey) (*CreateOrderRequest, error) {
// VerifySignatureMD5 验证MD5签名
func VerifySignatureMD5(c *gin.Context, apiKey *model.MerchantAPIKey) (*CreateOrderRequest, error) {
var req EPayRequest
if err := c.ShouldBind(&req); err != nil {
return nil, err
Expand All @@ -213,10 +216,10 @@ func VerifySignature(c *gin.Context, apiKey *model.MerchantAPIKey) (*CreateOrder
}

params["money"] = req.Amount.Truncate(2).StringFixed(2)
expectedSignFixed := GenerateSignature(params, apiKey.ClientSecret)
expectedSignFixed := GenerateSignature(params, apiKey.ClientSecret, true)

params["money"] = req.Amount.Truncate(2).String()
expectedSignTrimmed := GenerateSignature(params, apiKey.ClientSecret)
expectedSignTrimmed := GenerateSignature(params, apiKey.ClientSecret, true)

matchFixed := subtle.ConstantTimeCompare([]byte(strings.ToLower(expectedSignFixed)), []byte(strings.ToLower(req.Sign))) == 1
matchTrimmed := subtle.ConstantTimeCompare([]byte(strings.ToLower(expectedSignTrimmed)), []byte(strings.ToLower(req.Sign))) == 1
Expand All @@ -225,5 +228,49 @@ func VerifySignature(c *gin.Context, apiKey *model.MerchantAPIKey) (*CreateOrder
return nil, errors.New("签名验证失败")
}

return req.ToCreateOrderRequest(), nil
return NewCreateOrderRequest(req.OrderName, req.MerchantOrderNo, req.Amount, req.PayType), nil
}

// VerifySignatureEd25519 验证 Ed25519 签名
func VerifySignatureEd25519(c *gin.Context, apiKey *model.MerchantAPIKey) (*CreateOrderRequest, error) {
var req LDCPayRequest
if err := c.ShouldBind(&req); err != nil {
return nil, err
}

// 验证金额
if err := util.ValidateAmount(req.Amount); err != nil {
return nil, err
}

if err := apiKey.GetByClientID(db.DB(c.Request.Context()), req.ClientID); err != nil {
return nil, err
}

if len(apiKey.PublicKey) == 0 {
return nil, errors.New("商户未配置公钥")
}

signatureBytes, err := util.Base64Decode(req.Sign)
if err != nil {
return nil, errors.New("签名格式错误")
}

// 构建签名参数
params := map[string]string{
"client_id": req.ClientID,
"type": req.PayType,
"out_trade_no": util.DerefString(req.MerchantOrderNo),
"order_name": req.OrderName,
"money": req.Amount.Truncate(2).StringFixed(2),
}

signatureParam := GenerateSignature(params, apiKey.ClientSecret, false)
validTrimmed := util.Ed25519Verify(apiKey.PublicKey, []byte(signatureParam), signatureBytes)

if !validTrimmed {
return nil, errors.New("签名验证失败")
}

return NewCreateOrderRequest(req.OrderName, req.MerchantOrderNo, req.Amount, req.PayType), nil
}
Loading
Loading