@@ -475,48 +564,7 @@ export function ViewDisputeHistoryDialog({ order, viewer = 'payer' }: { order: O
) : disputeHistory ? (
-
-
-
-
-
- 消费方发起争议
- {formatDateTime(disputeHistory.created_at)}
-
-
- {parseDisputeReason(disputeHistory.reason).userReason}
-
-
-
-
-
-
-
-
-
- {disputeConfig.timelineText}
-
- {disputeConfig.showTimestamp && (
-
- {formatDateTime(disputeHistory.updated_at)}
-
- )}
-
-
- {order.status === 'refused' && (
-
- {parseDisputeReason(disputeHistory.reason).merchantReason || "未提供拒绝理由"}
-
- )}
-
- {order.status === 'refund' && disputeConfig.showContent && disputeConfig.content && (
-
- {disputeConfig.content}
-
- )}
-
-
-
+
) : (
无法加载争议记录
diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx
index 0d03ed99..aceb1151 100644
--- a/frontend/components/layout/sidebar.tsx
+++ b/frontend/components/layout/sidebar.tsx
@@ -60,6 +60,7 @@ import {
Layers,
Trophy,
ArrowUpRight,
+ ReceiptText,
} from "lucide-react"
import { useUser } from "@/contexts/user-context"
@@ -77,6 +78,7 @@ const data = {
{ title: "系统配置", url: "/admin/system", icon: ShieldCheck },
{ title: "积分配置", url: "/admin/credit", icon: Settings },
{ title: "用户管理", url: "/admin/users", icon: UserRound },
+ { title: "订单管理", url: "/admin/orders", icon: ReceiptText },
{ title: "任务管理", url: "/admin/tasks", icon: Layers },
],
document: [
diff --git a/frontend/lib/services/admin/admin.service.ts b/frontend/lib/services/admin/admin.service.ts
index d38b7a05..ed5904e5 100644
--- a/frontend/lib/services/admin/admin.service.ts
+++ b/frontend/lib/services/admin/admin.service.ts
@@ -12,6 +12,9 @@ import type {
ListUsersRequest,
ListUsersResponse,
UpdateUserStatusRequest,
+ ListAdminOrdersRequest,
+ ListAdminOrdersResponse,
+ RefundAdminOrderRequest,
} from './types';
export type { AdminUser } from './types';
@@ -369,4 +372,28 @@ export class AdminService extends BaseService {
): Promise {
return this.put(`/users/${ id }/status`, request);
}
+
+ // ==================== 订单管理 ====================
+
+ /**
+ * 获取后台订单列表
+ * @param request - 查询参数
+ * @returns 订单列表及总数
+ */
+ static async listOrders(request: ListAdminOrdersRequest): Promise {
+ return this.post('/orders', request);
+ }
+
+ /**
+ * 管理员退款
+ * @param id - 订单 ID
+ * @param request - 退款备注
+ * @returns void
+ */
+ static async refundOrder(
+ id: string,
+ request: RefundAdminOrderRequest
+ ): Promise {
+ return this.post(`/orders/${ id }/refund`, request);
+ }
}
diff --git a/frontend/lib/services/admin/index.ts b/frontend/lib/services/admin/index.ts
index 8c2964c5..054f4c8c 100644
--- a/frontend/lib/services/admin/index.ts
+++ b/frontend/lib/services/admin/index.ts
@@ -41,5 +41,11 @@ export type {
ListUsersRequest,
ListUsersResponse,
UpdateUserStatusRequest,
+ AdminOrder,
+ AdminOrderType,
+ AdminOrderStatus,
+ AdminOrderTransferStatus,
+ ListAdminOrdersRequest,
+ ListAdminOrdersResponse,
+ RefundAdminOrderRequest,
} from './types';
-
diff --git a/frontend/lib/services/admin/types.ts b/frontend/lib/services/admin/types.ts
index d9518e64..a3e9a6f6 100644
--- a/frontend/lib/services/admin/types.ts
+++ b/frontend/lib/services/admin/types.ts
@@ -229,3 +229,91 @@ export interface UpdateUserStatusRequest {
/** 是否激活 */
is_active: boolean;
}
+
+// ==================== 订单管理 ====================
+
+/**
+ * 后台订单类型
+ */
+export type AdminOrderType = 'payment' | 'transfer' | 'community' | 'online' | 'test' | 'distribute' | 'red_envelope_send' | 'red_envelope_receive' | 'red_envelope_refund';
+
+/**
+ * 后台订单状态
+ */
+export type AdminOrderStatus = 'success' | 'pending' | 'failed' | 'expired' | 'disputing' | 'refund' | 'refused';
+
+/**
+ * 后台订单结算状态
+ */
+export type AdminOrderTransferStatus = 'pending' | 'completed';
+
+/**
+ * 后台订单信息
+ */
+export interface AdminOrder {
+ id: string;
+ order_no: string;
+ order_name: string;
+ merchant_order_no: string | null;
+ client_id: string;
+ payer_user_id: string;
+ payee_user_id: string;
+ payer_username: string;
+ payee_username: string;
+ payer_avatar_url?: string;
+ payee_avatar_url?: string;
+ amount: string;
+ status: AdminOrderStatus;
+ type: AdminOrderType;
+ remark: string;
+ payment_type: string;
+ app_name?: string;
+ app_homepage_url?: string;
+ app_description?: string;
+ dispute_id?: string;
+ dispute_status?: 'disputing' | 'refund' | 'closed';
+ dispute_reason?: string;
+ dispute_created_at?: string | null;
+ dispute_updated_at?: string | null;
+ payee_transfer_status: AdminOrderTransferStatus;
+ payee_transfer_at?: string | null;
+ trade_time: string;
+ expires_at: string;
+ created_at: string;
+ updated_at: string;
+}
+
+/**
+ * 后台订单列表查询请求
+ */
+export interface ListAdminOrdersRequest {
+ page: number;
+ page_size: number;
+ types?: AdminOrderType[];
+ statuses?: AdminOrderStatus[];
+ client_id?: string;
+ merchant_order_no?: string;
+ start_time?: string;
+ end_time?: string;
+ id?: string;
+ order_name?: string;
+ payer_username?: string;
+ payee_username?: string;
+}
+
+/**
+ * 后台订单列表响应
+ */
+export interface ListAdminOrdersResponse {
+ orders: AdminOrder[];
+ total: number;
+ page: number;
+ page_size: number;
+}
+
+/**
+ * 后台退款请求
+ */
+export interface RefundAdminOrderRequest {
+ remark?: string;
+}
diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts
index 3ab73d67..d8c16a45 100644
--- a/frontend/lib/services/index.ts
+++ b/frontend/lib/services/index.ts
@@ -164,6 +164,13 @@ export type {
ListUsersRequest,
ListUsersResponse,
UpdateUserStatusRequest,
+ AdminOrder,
+ AdminOrderType,
+ AdminOrderStatus,
+ AdminOrderTransferStatus,
+ ListAdminOrdersRequest,
+ ListAdminOrdersResponse,
+ RefundAdminOrderRequest,
} from './admin';
// 用户服务
diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml
new file mode 100644
index 00000000..07399aa0
--- /dev/null
+++ b/frontend/pnpm-workspace.yaml
@@ -0,0 +1,4 @@
+allowBuilds:
+ core-js: true
+ sharp: true
+ unrs-resolver: true
diff --git a/internal/apps/admin/order/constants.go b/internal/apps/admin/order/constants.go
new file mode 100644
index 00000000..deb44753
--- /dev/null
+++ b/internal/apps/admin/order/constants.go
@@ -0,0 +1,24 @@
+/*
+Copyright 2025 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package order
+
+const (
+ // orderRemarkMaxLength 对齐 orders.remark 字段长度,避免追加管理员备注后写库失败。
+ orderRemarkMaxLength = 255
+ // disputeReasonMaxLength 对齐 disputes.reason 字段长度,避免追加管理员备注后写库失败。
+ disputeReasonMaxLength = 500
+)
diff --git a/internal/apps/admin/order/errs.go b/internal/apps/admin/order/errs.go
new file mode 100644
index 00000000..da9b23e0
--- /dev/null
+++ b/internal/apps/admin/order/errs.go
@@ -0,0 +1,22 @@
+/*
+Copyright 2025 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package order
+
+const (
+ orderNotRefundable = "订单不存在或状态不允许退款"
+ remarkTooLong = "管理员备注过长"
+)
diff --git a/internal/apps/admin/order/logics.go b/internal/apps/admin/order/logics.go
new file mode 100644
index 00000000..54b6123e
--- /dev/null
+++ b/internal/apps/admin/order/logics.go
@@ -0,0 +1,171 @@
+/*
+Copyright 2025 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package order
+
+import (
+ "context"
+ "errors"
+
+ "github.com/linux-do/credit/internal/db"
+ "github.com/linux-do/credit/internal/model"
+ "github.com/linux-do/credit/internal/service"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// listOrders 查询后台订单列表,并补充应用、争议、用户和延迟结算信息。
+func listOrders(ctx context.Context, req *listOrdersRequest) (*listOrdersResponse, error) {
+ query := db.DB(ctx).Model(&model.Order{})
+ if len(req.Types) > 0 {
+ query = query.Where("orders.type IN ?", req.Types)
+ }
+ if len(req.Statuses) > 0 {
+ query = query.Where("orders.status IN ?", req.Statuses)
+ }
+ if req.ClientID != "" {
+ query = query.Where("orders.client_id = ?", req.ClientID)
+ }
+ if req.MerchantOrderNo != "" {
+ query = query.Where("orders.merchant_order_no = ?", req.MerchantOrderNo)
+ }
+ if req.ID != nil {
+ query = query.Where("orders.id = ?", *req.ID)
+ }
+ if req.OrderName != "" {
+ query = query.Where("orders.order_name LIKE ?", req.OrderName+"%")
+ }
+ if req.StartTime != nil {
+ query = query.Where("orders.created_at >= ?", req.StartTime)
+ }
+ if req.EndTime != nil {
+ query = query.Where("orders.created_at <= ?", req.EndTime)
+ }
+
+ var err error
+ if query, err = applyOrderUsernameFilter(query, "orders.payer_user_id", req.PayerUsername); err != nil {
+ return nil, err
+ }
+ if query, err = applyOrderUsernameFilter(query, "orders.payee_user_id", req.PayeeUsername); err != nil {
+ return nil, err
+ }
+
+ var total int64
+ if err := query.Count(&total).Error; err != nil {
+ return nil, err
+ }
+
+ response := &listOrdersResponse{
+ Total: total,
+ Page: req.Page,
+ PageSize: req.PageSize,
+ }
+
+ offset := (req.Page - 1) * req.PageSize
+ if err := query.
+ Select("orders.*, merchant_api_keys.app_name, merchant_api_keys.app_homepage_url, merchant_api_keys.app_description, disputes.id as dispute_id, disputes.status as dispute_status, disputes.reason as dispute_reason, disputes.created_at as dispute_created_at, disputes.updated_at as dispute_updated_at, payer_user.username as payer_username, payee_user.username as payee_username, payer_user.avatar_url as payer_avatar_url, payee_user.avatar_url as payee_avatar_url, COALESCE(order_transfers.status, ?) as payee_transfer_status, order_transfers.transfer_at as payee_transfer_at", model.OrderTransferStatusCompleted).
+ Joins("LEFT JOIN merchant_api_keys ON orders.client_id = merchant_api_keys.client_id").
+ Joins("LEFT JOIN disputes ON orders.id = disputes.order_id").
+ Joins("LEFT JOIN users as payer_user ON orders.payer_user_id = payer_user.id").
+ Joins("LEFT JOIN users as payee_user ON orders.payee_user_id = payee_user.id").
+ Joins("LEFT JOIN order_transfers ON orders.id = order_transfers.order_id").
+ Order("orders.created_at DESC").
+ Offset(offset).
+ Limit(req.PageSize).
+ Find(&response.Orders).Error; err != nil {
+ return nil, err
+ }
+
+ return response, nil
+}
+
+// refundOrder 执行管理员退款;有争议时更新争议,无争议时只按需追加订单备注。
+func refundOrder(ctx context.Context, id uint64, req *refundOrderRequest, adminUserID uint64) error {
+ return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
+ var order model.Order
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
+ Where("id = ? AND status IN ? AND type IN ?", id, []model.OrderStatus{
+ model.OrderStatusSuccess,
+ model.OrderStatusDisputing,
+ model.OrderStatusRefused,
+ }, []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
+ First(&order).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New(orderNotRefundable)
+ }
+ return err
+ }
+
+ var merchantUser model.User
+ if err := merchantUser.GetByID(tx, order.PayeeUserID); err != nil {
+ return err
+ }
+
+ var merchantPayConfig model.UserPayConfig
+ if err := merchantPayConfig.GetByPayScore(tx, merchantUser.PayScore); err != nil {
+ return err
+ }
+
+ var dispute model.Dispute
+ hasDispute := false
+ // 存在争议时锁住争议记录,后续把管理员备注追加到争议原因;无争议才追加到订单备注。
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
+ Where("order_id = ?", order.ID).First(&dispute).Error; err != nil {
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ } else {
+ hasDispute = true
+ if dispute.Status == model.DisputeStatusRefund {
+ return errors.New(orderNotRefundable)
+ }
+ }
+
+ if err := service.RefundOrder(tx, &order, &merchantPayConfig); err != nil {
+ return err
+ }
+
+ // 有争议时,管理员退款同时关闭争议;备注追加到争议原因,方便和双方争议对话一起查看。
+ if hasDispute {
+ updates := map[string]interface{}{
+ "status": model.DisputeStatusRefund,
+ "handler_user_id": adminUserID,
+ }
+ if req.Remark != "" {
+ reason, err := appendAdminRemark(dispute.Reason, req.Remark, disputeReasonMaxLength)
+ if err != nil {
+ return err
+ }
+ updates["reason"] = reason
+ }
+ return tx.Model(&model.Dispute{}).
+ Where("id = ?", dispute.ID).
+ Updates(updates).Error
+ }
+
+ // 普通订单没有争议记录,只有管理员填写备注时才落到订单备注。
+ if req.Remark == "" {
+ return nil
+ }
+ nextRemark, err := appendAdminRemark(order.Remark, req.Remark, orderRemarkMaxLength)
+ if err != nil {
+ return err
+ }
+ return tx.Model(&model.Order{}).
+ Where("id = ?", order.ID).
+ Update("remark", nextRemark).Error
+ })
+}
diff --git a/internal/apps/admin/order/routers.go b/internal/apps/admin/order/routers.go
new file mode 100644
index 00000000..b58a88e7
--- /dev/null
+++ b/internal/apps/admin/order/routers.go
@@ -0,0 +1,138 @@
+/*
+Copyright 2025 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package order
+
+import (
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/linux-do/credit/internal/apps/oauth"
+ "github.com/linux-do/credit/internal/model"
+ "github.com/linux-do/credit/internal/service"
+ "github.com/linux-do/credit/internal/util"
+)
+
+// listOrdersRequest 后台订单列表查询请求。
+type listOrdersRequest struct {
+ Page int `json:"page" binding:"min=1"`
+ PageSize int `json:"page_size" binding:"min=1,max=100"`
+ Types []string `json:"types" binding:"omitempty,dive,oneof=payment transfer community online test distribute red_envelope_send red_envelope_receive red_envelope_refund"`
+ Statuses []string `json:"statuses" binding:"omitempty,dive,oneof=success pending failed expired disputing refund refused"`
+ ClientID string `json:"client_id" binding:"omitempty,max=64"`
+ MerchantOrderNo string `json:"merchant_order_no" binding:"omitempty,max=64"`
+ StartTime *time.Time `json:"start_time" binding:"omitempty"`
+ EndTime *time.Time `json:"end_time" binding:"omitempty,gtfield=StartTime"`
+ ID *uint64 `json:"id,string" binding:"omitempty"`
+ OrderName string `json:"order_name" binding:"omitempty,max=64"`
+ PayerUsername string `json:"payer_username" binding:"omitempty,max=255"`
+ PayeeUsername string `json:"payee_username" binding:"omitempty,max=255"`
+}
+
+// adminOrder 后台订单列表项,补充应用、争议、用户和延迟结算信息。
+type adminOrder struct {
+ model.Order
+ AppName string `json:"app_name"`
+ AppHomepageURL string `json:"app_homepage_url"`
+ AppDescription string `json:"app_description"`
+ DisputeID *uint64 `json:"dispute_id,string"`
+ DisputeStatus *model.DisputeStatus `json:"dispute_status"`
+ DisputeReason string `json:"dispute_reason"`
+ DisputeCreatedAt *time.Time `json:"dispute_created_at"`
+ DisputeUpdatedAt *time.Time `json:"dispute_updated_at"`
+ PayerUsername string `json:"payer_username"`
+ PayeeUsername string `json:"payee_username"`
+ PayerAvatarURL string `json:"payer_avatar_url"`
+ PayeeAvatarURL string `json:"payee_avatar_url"`
+ PayeeTransferStatus string `json:"payee_transfer_status"`
+ PayeeTransferAt *time.Time `json:"payee_transfer_at"`
+}
+
+// listOrdersResponse 后台订单列表响应。
+type listOrdersResponse struct {
+ Orders []adminOrder `json:"orders"`
+ Total int64 `json:"total"`
+ Page int `json:"page"`
+ PageSize int `json:"page_size"`
+}
+
+// refundOrderRequest 后台退款请求。
+type refundOrderRequest struct {
+ ID uint64 `uri:"id" json:"-" binding:"required,gt=0"`
+ Remark string `json:"remark" binding:"omitempty,max=100"`
+}
+
+// ListOrders 获取后台订单列表
+// @Tags admin
+// @Accept json
+// @Produce json
+// @Param request body listOrdersRequest true "request body"
+// @Success 200 {object} util.ResponseAny
+// @Router /api/v1/admin/orders [post]
+func ListOrders(c *gin.Context) {
+ var req listOrdersRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, util.Err(err.Error()))
+ return
+ }
+
+ response, err := listOrders(c.Request.Context(), &req)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ return
+ }
+
+ c.JSON(http.StatusOK, util.OK(response))
+}
+
+// RefundOrder 管理员退款
+// @Tags admin
+// @Accept json
+// @Produce json
+// @Param id path int true "订单ID"
+// @Param request body refundOrderRequest false "request body"
+// @Success 200 {object} util.ResponseAny
+// @Router /api/v1/admin/orders/{id}/refund [post]
+func RefundOrder(c *gin.Context) {
+ var req refundOrderRequest
+ if err := c.ShouldBindUri(&req); err != nil {
+ c.JSON(http.StatusBadRequest, util.Err(err.Error()))
+ return
+ }
+ if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
+ c.JSON(http.StatusBadRequest, util.Err(err.Error()))
+ return
+ }
+ req.Remark = strings.TrimSpace(req.Remark)
+
+ adminUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
+
+ if err := refundOrder(c.Request.Context(), req.ID, &req, adminUser.ID); err != nil {
+ switch err.Error() {
+ case orderNotRefundable, service.RefundOrderStatusInvalid, service.RefundOrderTypeInvalid, remarkTooLong:
+ c.JSON(http.StatusBadRequest, util.Err(err.Error()))
+ default:
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ }
+ return
+ }
+
+ c.JSON(http.StatusOK, util.OKNil())
+}
diff --git a/internal/apps/admin/order/utils.go b/internal/apps/admin/order/utils.go
new file mode 100644
index 00000000..77be9971
--- /dev/null
+++ b/internal/apps/admin/order/utils.go
@@ -0,0 +1,61 @@
+/*
+Copyright 2025 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package order
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/linux-do/credit/internal/model"
+ "gorm.io/gorm"
+)
+
+// applyOrderUsernameFilter 先按用户名前缀查用户 ID,再把 ID 条件追加回订单查询。
+func applyOrderUsernameFilter(query *gorm.DB, column string, username string) (*gorm.DB, error) {
+ username = strings.TrimSpace(username)
+ if username == "" {
+ return query, nil
+ }
+
+ var ids []uint64
+ if err := query.Session(&gorm.Session{NewDB: true}).
+ Model(&model.User{}).
+ Where("username LIKE ?", username+"%").
+ Pluck("id", &ids).Error; err != nil {
+ return nil, err
+ }
+ if len(ids) == 0 {
+ return query.Where("1 = 0"), nil
+ }
+ return query.Where(column+" IN ?", ids), nil
+}
+
+// appendAdminRemark 追加管理员备注并校验目标字段长度,避免覆盖原备注或争议原因。
+func appendAdminRemark(original string, remark string, maxLength int) (string, error) {
+ suffix := fmt.Sprintf("[管理员: %s]", remark)
+ if original != "" {
+ suffix = " " + suffix
+ }
+
+ next := original + suffix
+ if utf8.RuneCountInString(next) > maxLength {
+ return "", errors.New(remarkTooLong)
+ }
+ return next, nil
+}
diff --git a/internal/apps/dispute/routers.go b/internal/apps/dispute/routers.go
index 7339b43d..67efd895 100644
--- a/internal/apps/dispute/routers.go
+++ b/internal/apps/dispute/routers.go
@@ -26,6 +26,7 @@ import (
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
+ "github.com/linux-do/credit/internal/service"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
"gorm.io/gorm"
@@ -292,38 +293,12 @@ func RefundReview(c *gin.Context) {
}
if status == model.DisputeStatusRefund {
- var payerUser model.User
- if err := payerUser.GetByID(tx, order.PayerUserID); err != nil {
- return err
- }
-
// 获取商家的支付配置
var merchantPayConfig model.UserPayConfig
if err := merchantPayConfig.GetByPayScore(tx, merchantUser.PayScore); err != nil {
return err
}
- merchantScoreDecrease := order.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
- if err := tx.Model(&model.User{}).
- Where("id = ?", merchantUser.ID).
- UpdateColumns(map[string]interface{}{
- "available_balance": gorm.Expr("available_balance - ?", order.Amount),
- "total_receive": gorm.Expr("total_receive - ?", order.Amount),
- "pay_score": gorm.Expr("pay_score - ?", merchantScoreDecrease),
- }).Error; err != nil {
- return err
- }
-
- if err := tx.Model(&model.User{}).
- Where("id = ?", payerUser.ID).
- UpdateColumns(map[string]interface{}{
- "available_balance": gorm.Expr("available_balance + ?", order.Amount),
- "total_payment": gorm.Expr("total_payment - ?", order.Amount),
- "pay_score": gorm.Expr("pay_score - ?", order.Amount.Round(0).IntPart()),
- }).Error; err != nil {
- return err
- }
-
if err := tx.Model(&model.Dispute{}).
Where("id = ?", dispute.ID).
Updates(map[string]interface{}{
@@ -333,9 +308,7 @@ func RefundReview(c *gin.Context) {
return err
}
- if err := tx.Model(&model.Order{}).
- Where("id = ?", order.ID).
- Update("status", model.OrderStatusRefund).Error; err != nil {
+ if err := service.RefundOrder(tx, &order, &merchantPayConfig); err != nil {
return err
}
} else if status == model.DisputeStatusClosed {
diff --git a/internal/apps/dispute/tasks.go b/internal/apps/dispute/tasks.go
index 9396cbcb..c45c1aef 100644
--- a/internal/apps/dispute/tasks.go
+++ b/internal/apps/dispute/tasks.go
@@ -28,6 +28,7 @@ import (
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/model"
+ "github.com/linux-do/credit/internal/service"
"github.com/linux-do/credit/internal/task"
"github.com/linux-do/credit/internal/task/scheduler"
"gorm.io/gorm"
@@ -129,11 +130,8 @@ func HandleAutoRefundSingleDispute(ctx context.Context, t *asynq.Task) error {
return err
}
- // 获取付款方和收款方用户
- var payerUser, payeeUser model.User
- if err := payerUser.GetByID(tx, order.PayerUserID); err != nil {
- return fmt.Errorf("查询付款方用户失败: %w", err)
- }
+ // 获取收款方用户
+ var payeeUser model.User
if err := payeeUser.GetByID(tx, order.PayeeUserID); err != nil {
return fmt.Errorf("查询收款方用户失败: %w", err)
}
@@ -144,31 +142,6 @@ func HandleAutoRefundSingleDispute(ctx context.Context, t *asynq.Task) error {
return fmt.Errorf("查询商家支付配置失败: %w", err)
}
- // 计算商家积分减少:订单金额 × 商家的 score_rate
- merchantScoreDecrease := order.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
-
- // 商家(收款方)退款:扣除可用余额、总收款和积分
- if err := tx.Model(&model.User{}).
- Where("id = ?", payeeUser.ID).
- UpdateColumns(map[string]interface{}{
- "available_balance": gorm.Expr("available_balance - ?", order.Amount),
- "total_receive": gorm.Expr("total_receive - ?", order.Amount),
- "pay_score": gorm.Expr("pay_score - ?", merchantScoreDecrease),
- }).Error; err != nil {
- return fmt.Errorf("商家退款失败: %w", err)
- }
-
- // 付款方收到退款:增加可用余额,减少总支付和支付积分
- if err := tx.Model(&model.User{}).
- Where("id = ?", payerUser.ID).
- UpdateColumns(map[string]interface{}{
- "available_balance": gorm.Expr("available_balance + ?", order.Amount),
- "total_payment": gorm.Expr("total_payment - ?", order.Amount),
- "pay_score": gorm.Expr("pay_score - ?", order.Amount.Round(0).IntPart()),
- }).Error; err != nil {
- return fmt.Errorf("付款方退款失败: %w", err)
- }
-
// 更新争议状态为已退款,handler_user_id 设为 0(系统自动处理)
if err := tx.Model(&model.Dispute{}).
Where("id = ?", dispute.ID).
@@ -179,15 +152,12 @@ func HandleAutoRefundSingleDispute(ctx context.Context, t *asynq.Task) error {
return fmt.Errorf("更新争议状态失败: %w", err)
}
- // 更新订单状态为已退款
- if err := tx.Model(&model.Order{}).
- Where("id = ?", order.ID).
- Update("status", model.OrderStatusRefund).Error; err != nil {
- return fmt.Errorf("更新订单状态失败: %w", err)
+ if err := service.RefundOrder(tx, &order, &merchantPayConfig); err != nil {
+ return fmt.Errorf("订单退款失败: %w", err)
}
- logger.InfoF(ctx, "自动退款成功: 争议[ID:%d] 订单[ID:%d] 金额[%s] 付款方[%s] 商家[%s]",
- dispute.ID, order.ID, order.Amount.String(), payerUser.Username, payeeUser.Username)
+ logger.InfoF(ctx, "自动退款成功: 争议[ID:%d] 订单[ID:%d] 金额[%s] 付款方[ID:%d] 商家[%s]",
+ dispute.ID, order.ID, order.Amount.String(), order.PayerUserID, payeeUser.Username)
return nil
}); err != nil {
diff --git a/internal/apps/payment/routers.go b/internal/apps/payment/routers.go
index 20803003..b6d6fa4d 100644
--- a/internal/apps/payment/routers.go
+++ b/internal/apps/payment/routers.go
@@ -264,7 +264,7 @@ func RefundMerchantOrder(c *gin.Context) {
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
var order model.Order
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
- Where("id = ? AND client_id = ? AND status = ? AND amount = ? AND type IN ?", req.TradeNo, req.ClientID, model.OrderStatusSuccess, req.Amount, []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
+ Where("id = ? AND client_id = ? AND payee_user_id = ? AND status = ? AND amount = ? AND type IN ?", req.TradeNo, req.ClientID, apiKey.UserID, model.OrderStatusSuccess, req.Amount, []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(OrderNotFound)
@@ -272,11 +272,6 @@ func RefundMerchantOrder(c *gin.Context) {
return err
}
- var payerUser model.User
- if err := payerUser.GetByID(tx, order.PayerUserID); err != nil {
- return err
- }
-
var merchantUser model.User
if err := tx.Where("id = ? AND is_active = ?", apiKey.UserID, true).First(&merchantUser).Error; err != nil {
return err
@@ -287,30 +282,7 @@ func RefundMerchantOrder(c *gin.Context) {
return err
}
- merchantScoreDecrease := order.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
- if err := tx.Model(&model.User{}).
- Where("id = ?", merchantUser.ID).
- UpdateColumns(map[string]interface{}{
- "available_balance": gorm.Expr("available_balance - ?", order.Amount),
- "total_receive": gorm.Expr("total_receive - ?", order.Amount),
- "pay_score": gorm.Expr("pay_score - ?", merchantScoreDecrease),
- }).Error; err != nil {
- return err
- }
-
- if err := tx.Model(&model.User{}).
- Where("id = ?", payerUser.ID).
- UpdateColumns(map[string]interface{}{
- "available_balance": gorm.Expr("available_balance + ?", order.Amount),
- "total_payment": gorm.Expr("total_payment - ?", order.Amount),
- "pay_score": gorm.Expr("pay_score - ?", order.Amount.Round(0).IntPart()),
- }).Error; err != nil {
- return err
- }
-
- if err := tx.Model(&model.Order{}).
- Where("id = ?", order.ID).
- Update("status", model.OrderStatusRefund).Error; err != nil {
+ if err := service.RefundOrder(tx, &order, &merchantPayConfig); err != nil {
return err
}
diff --git a/internal/model/orders.go b/internal/model/orders.go
index 8de3e862..8cd064fc 100644
--- a/internal/model/orders.go
+++ b/internal/model/orders.go
@@ -60,7 +60,7 @@ type Order struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
OrderNo string `json:"order_no" gorm:"-"`
OrderName string `json:"order_name" gorm:"size:64;not null;index"`
- MerchantOrderNo *string `json:"merchant_order_no" gorm:"size:64;uniqueIndex:idx_orders_client_merchant_order,priority:2"`
+ MerchantOrderNo *string `json:"merchant_order_no" gorm:"size:64;index:idx_orders_merchant_order_no;uniqueIndex:idx_orders_client_merchant_order,priority:2"`
ClientID string `json:"client_id" gorm:"size:64;index:idx_orders_client_status_created,priority:1;index:idx_orders_client_payee,priority:1;index:idx_orders_client_payer,priority:1;uniqueIndex:idx_orders_client_merchant_order,priority:1"`
PayerUserID uint64 `json:"payer_user_id" gorm:"index:idx_orders_payer_status_type_created,priority:1;index:idx_orders_payer_status_type_trade,priority:1;index:idx_orders_client_payer,priority:2"`
PayeeUserID uint64 `json:"payee_user_id" gorm:"index:idx_orders_payee_status_type_created,priority:1;index:idx_orders_client_payee,priority:2"`
@@ -76,7 +76,7 @@ type Order struct {
PaymentLinkID *uint64 `json:"payment_link_id,string" gorm:"index:idx_orders_payment_link_status,priority:1"`
TradeTime time.Time `json:"trade_time" gorm:"index:idx_orders_payer_status_type_trade,priority:4"`
ExpiresAt time.Time `json:"expires_at" gorm:"not null;index:idx_orders_status_expires,priority:2"`
- CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_orders_payee_status_type_created,priority:4;index:idx_orders_payer_status_type_created,priority:4;index:idx_orders_client_status_created,priority:3"`
+ CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_orders_created;index:idx_orders_payee_status_type_created,priority:4;index:idx_orders_payer_status_type_created,priority:4;index:idx_orders_client_status_created,priority:3"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
diff --git a/internal/router/router.go b/internal/router/router.go
index 05cbc377..6ba5c0af 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -28,6 +28,7 @@ import (
"time"
"github.com/linux-do/credit/internal/apps/admin"
+ admin_order "github.com/linux-do/credit/internal/apps/admin/order"
admin_task "github.com/linux-do/credit/internal/apps/admin/task"
admin_user "github.com/linux-do/credit/internal/apps/admin/user"
publicconfig "github.com/linux-do/credit/internal/apps/config"
@@ -252,6 +253,10 @@ func Serve() {
adminRouter.GET("/users", admin_user.ListUsers)
adminRouter.PUT("/users/:id/status", admin_user.UpdateUserStatus)
+ // Orders
+ adminRouter.POST("/orders", admin_order.ListOrders)
+ adminRouter.POST("/orders/:id/refund", admin_order.RefundOrder)
+
// System Config
adminRouter.POST("/system-configs", system_config.CreateSystemConfig)
adminRouter.GET("/system-configs", system_config.ListSystemConfigs)
diff --git a/internal/service/payment.go b/internal/service/payment.go
index 85d8ff08..0b8178e6 100644
--- a/internal/service/payment.go
+++ b/internal/service/payment.go
@@ -39,6 +39,13 @@ const (
BalanceDeduct
)
+const (
+ RefundOrderStatusInvalid = "订单状态不允许退款"
+ RefundOrderTypeInvalid = "订单类型不允许退款"
+ RefundOrderPayerNotFound = "付款方不存在"
+ RefundOrderMerchantNotFound = "商家不存在"
+)
+
// BalanceUpdateOptions 余额更新选项
type BalanceUpdateOptions struct {
UserID uint64
@@ -161,6 +168,66 @@ func CalculateFee(amount decimal.Decimal, feeRate decimal.Decimal) (fee decimal.
return
}
+// RefundOrder 回滚订单资金并将订单置为已退款。调用方需要先在事务内锁定订单行。
+func RefundOrder(tx *gorm.DB, order *model.Order, merchantPayConfig *model.UserPayConfig) error {
+ if order.Type != model.OrderTypePayment && order.Type != model.OrderTypeOnline {
+ return errors.New(RefundOrderTypeInvalid)
+ }
+
+ switch order.Status {
+ case model.OrderStatusSuccess, model.OrderStatusDisputing, model.OrderStatusRefused:
+ default:
+ return errors.New(RefundOrderStatusInvalid)
+ }
+
+ payerResult := tx.Model(&model.User{}).
+ Where("id = ?", order.PayerUserID).
+ UpdateColumns(map[string]interface{}{
+ "available_balance": gorm.Expr("available_balance + ?", order.Amount),
+ "total_payment": gorm.Expr("total_payment - ?", order.Amount),
+ "pay_score": gorm.Expr("pay_score - ?", order.Amount.Round(0).IntPart()),
+ })
+ if payerResult.Error != nil {
+ return payerResult.Error
+ }
+ if payerResult.RowsAffected == 0 {
+ return errors.New(RefundOrderPayerNotFound)
+ }
+
+ merchantScoreDecrease := order.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
+ merchantResult := tx.Model(&model.User{}).
+ Where("id = ?", order.PayeeUserID).
+ UpdateColumns(map[string]interface{}{
+ "available_balance": gorm.Expr("available_balance - ?", order.Amount),
+ "total_receive": gorm.Expr("total_receive - ?", order.Amount),
+ "pay_score": gorm.Expr("pay_score - ?", merchantScoreDecrease),
+ })
+ if merchantResult.Error != nil {
+ return merchantResult.Error
+ }
+ if merchantResult.RowsAffected == 0 {
+ return errors.New(RefundOrderMerchantNotFound)
+ }
+
+ // 更新订单状态
+ result := tx.Model(&model.Order{}).
+ Where("id = ? AND status IN ?", order.ID, []model.OrderStatus{
+ model.OrderStatusSuccess,
+ model.OrderStatusDisputing,
+ model.OrderStatusRefused,
+ }).
+ Update("status", model.OrderStatusRefund)
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return errors.New(RefundOrderStatusInvalid)
+ }
+
+ order.Status = model.OrderStatusRefund
+ return nil
+}
+
// ValidateTestModePayment 验证测试模式下的支付权限
// 返回 error:nil 表示允许支付,非 nil 表示拒绝支付
func ValidateTestModePayment(currentUserID, merchantUserID uint64, isTestMode bool) error {
From 58fcae756160a05215d08c06afd672f1ff959067 Mon Sep 17 00:00:00 2001
From: yyg-max <175597134+yyg-max@users.noreply.github.com>
Date: Fri, 26 Jun 2026 17:36:14 +0800
Subject: [PATCH 2/2] fix(docs): regenerate swagger definitions
---
docs/docs.go | 13 -------------
docs/swagger.json | 13 -------------
docs/swagger.yaml | 13 -------------
3 files changed, 39 deletions(-)
diff --git a/docs/docs.go b/docs/docs.go
index 2d80e61c..cbb8e673 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -2217,63 +2217,51 @@ const docTemplate = `{
"type": "object",
"properties": {
"client_id": {
- "description": "ClientID 商户应用 Client ID。",
"type": "string",
"maxLength": 64
},
"end_time": {
- "description": "EndTime 创建时间终点。",
"type": "string"
},
"id": {
- "description": "ID 订单 ID。",
"type": "string",
"example": "0"
},
"merchant_order_no": {
- "description": "MerchantOrderNo 商户订单号。",
"type": "string",
"maxLength": 64
},
"order_name": {
- "description": "OrderName 订单名称前缀。",
"type": "string",
"maxLength": 64
},
"page": {
- "description": "Page 页码,从 1 开始。",
"type": "integer",
"minimum": 1
},
"page_size": {
- "description": "PageSize 每页数量。",
"type": "integer",
"maximum": 100,
"minimum": 1
},
"payee_username": {
- "description": "PayeeUsername 服务方用户名前缀。",
"type": "string",
"maxLength": 255
},
"payer_username": {
- "description": "PayerUsername 消费方用户名前缀。",
"type": "string",
"maxLength": 255
},
"start_time": {
- "description": "StartTime 创建时间起点。",
"type": "string"
},
"statuses": {
- "description": "Statuses 订单状态筛选。",
"type": "array",
"items": {
"type": "string"
}
},
"types": {
- "description": "Types 订单类型筛选。",
"type": "array",
"items": {
"type": "string"
@@ -2285,7 +2273,6 @@ const docTemplate = `{
"type": "object",
"properties": {
"remark": {
- "description": "Remark 管理员备注,可选;有争议时追加到争议原因,无争议时追加到订单备注。",
"type": "string",
"maxLength": 100
}
diff --git a/docs/swagger.json b/docs/swagger.json
index a962a214..003dc0fb 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -2208,63 +2208,51 @@
"type": "object",
"properties": {
"client_id": {
- "description": "ClientID 商户应用 Client ID。",
"type": "string",
"maxLength": 64
},
"end_time": {
- "description": "EndTime 创建时间终点。",
"type": "string"
},
"id": {
- "description": "ID 订单 ID。",
"type": "string",
"example": "0"
},
"merchant_order_no": {
- "description": "MerchantOrderNo 商户订单号。",
"type": "string",
"maxLength": 64
},
"order_name": {
- "description": "OrderName 订单名称前缀。",
"type": "string",
"maxLength": 64
},
"page": {
- "description": "Page 页码,从 1 开始。",
"type": "integer",
"minimum": 1
},
"page_size": {
- "description": "PageSize 每页数量。",
"type": "integer",
"maximum": 100,
"minimum": 1
},
"payee_username": {
- "description": "PayeeUsername 服务方用户名前缀。",
"type": "string",
"maxLength": 255
},
"payer_username": {
- "description": "PayerUsername 消费方用户名前缀。",
"type": "string",
"maxLength": 255
},
"start_time": {
- "description": "StartTime 创建时间起点。",
"type": "string"
},
"statuses": {
- "description": "Statuses 订单状态筛选。",
"type": "array",
"items": {
"type": "string"
}
},
"types": {
- "description": "Types 订单类型筛选。",
"type": "array",
"items": {
"type": "string"
@@ -2276,7 +2264,6 @@
"type": "object",
"properties": {
"remark": {
- "description": "Remark 管理员备注,可选;有争议时追加到争议原因,无争议时追加到订单备注。",
"type": "string",
"maxLength": 100
}
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index 58c404e2..f42a3d17 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -234,51 +234,39 @@ definitions:
order.listOrdersRequest:
properties:
client_id:
- description: ClientID 商户应用 Client ID。
maxLength: 64
type: string
end_time:
- description: EndTime 创建时间终点。
type: string
id:
- description: ID 订单 ID。
example: "0"
type: string
merchant_order_no:
- description: MerchantOrderNo 商户订单号。
maxLength: 64
type: string
order_name:
- description: OrderName 订单名称前缀。
maxLength: 64
type: string
page:
- description: Page 页码,从 1 开始。
minimum: 1
type: integer
page_size:
- description: PageSize 每页数量。
maximum: 100
minimum: 1
type: integer
payee_username:
- description: PayeeUsername 服务方用户名前缀。
maxLength: 255
type: string
payer_username:
- description: PayerUsername 消费方用户名前缀。
maxLength: 255
type: string
start_time:
- description: StartTime 创建时间起点。
type: string
statuses:
- description: Statuses 订单状态筛选。
items:
type: string
type: array
types:
- description: Types 订单类型筛选。
items:
type: string
type: array
@@ -286,7 +274,6 @@ definitions:
order.refundOrderRequest:
properties:
remark:
- description: Remark 管理员备注,可选;有争议时追加到争议原因,无争议时追加到订单备注。
maxLength: 100
type: string
type: object