This commit is contained in:
Your Name
2026-11-10 16:21:09 +08:00
parent 0031f79eb3
commit 13ee993716
28 changed files with 557 additions and 145 deletions
+1 -1
View File
@@ -1 +1 @@
57dce2e3bd928effae6f9684d54fc4f44a1865a1ee257f92d3912bc96e9ea5fd
fefa866e36c754224a89fa9f096d732bb526fb931113a92364af6c5a9b14b1bd
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+36 -2
View File
@@ -851,9 +851,43 @@ func (a *App) adminUpdateConfig(w http.ResponseWriter, r *http.Request) {
}
func (a *App) adminAuditLogs(w http.ResponseWriter, r *http.Request) {
page, size, offset := pagination(r)
// 审计日志现在记的是真金白银的动作(退款、放款、指定喂养者)。查一条
// "这笔钱是谁退的"不能靠一页页翻,所以这里按人、动作、对象和时间筛。
where := " WHERE 1=1"
args := []any{}
if admin := strings.TrimSpace(r.URL.Query().Get("adminId")); admin != "" {
where += " AND l.admin_user_id=?"
args = append(args, admin)
}
if action := strings.TrimSpace(r.URL.Query().Get("action")); action != "" {
where += " AND l.action=?"
args = append(args, action)
}
if targetType := strings.TrimSpace(r.URL.Query().Get("targetType")); targetType != "" {
where += " AND l.target_type=?"
args = append(args, targetType)
}
if targetID := strings.TrimSpace(r.URL.Query().Get("targetId")); targetID != "" {
where += " AND l.target_id=?"
args = append(args, targetID)
}
if keyword := strings.TrimSpace(r.URL.Query().Get("keyword")); keyword != "" {
where += " AND (a.real_name LIKE ? OR l.request_data LIKE ?)"
like := "%" + keyword + "%"
args = append(args, like, like)
}
if from := strings.TrimSpace(r.URL.Query().Get("from")); from != "" {
where += " AND l.created_at>=?"
args = append(args, from)
}
if to := strings.TrimSpace(r.URL.Query().Get("to")); to != "" {
where += " AND l.created_at<=?"
args = append(args, to)
}
var total int64
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM admin_audit_logs`).Scan(&total)
rows, err := a.db.QueryContext(r.Context(), `SELECT l.id,l.admin_user_id,a.real_name,l.action,l.target_type,l.target_id,l.request_data,l.ip,l.created_at FROM admin_audit_logs l JOIN admin_users a ON a.id=l.admin_user_id ORDER BY l.created_at DESC LIMIT ? OFFSET ?`, size, offset)
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM admin_audit_logs l
JOIN admin_users a ON a.id=l.admin_user_id`+where, args...).Scan(&total)
rows, err := a.db.QueryContext(r.Context(), `SELECT l.id,l.admin_user_id,a.real_name,l.action,l.target_type,l.target_id,l.request_data,l.ip,l.created_at FROM admin_audit_logs l JOIN admin_users a ON a.id=l.admin_user_id`+where+` ORDER BY l.created_at DESC LIMIT ? OFFSET ?`, append(args, size, offset)...)
if err != nil {
fail(w, 500, 50001, "查询失败")
return
+19
View File
@@ -40,6 +40,25 @@ func (a *App) adminAILogs(w http.ResponseWriter, r *http.Request) {
where = append(where, "l.status=?")
args = append(args, status)
}
// 排查"这个 AI 为什么这么回",看单条没用,要能把一整个会话拉出来看顺序。
if conversation := strings.TrimSpace(r.URL.Query().Get("conversationId")); conversation != "" {
value, err := strconv.ParseInt(conversation, 10, 64)
if err != nil || value <= 0 {
fail(w, http.StatusBadRequest, 20001, "会话筛选无效")
return
}
where = append(where, "l.conversation_id=?")
args = append(args, value)
}
if agent := strings.TrimSpace(r.URL.Query().Get("agentUserId")); agent != "" {
value, err := strconv.ParseInt(agent, 10, 64)
if err != nil || value <= 0 {
fail(w, http.StatusBadRequest, 20001, "托管账号筛选无效")
return
}
where = append(where, "l.agent_user_id=?")
args = append(args, value)
}
if days := strings.TrimSpace(r.URL.Query().Get("days")); days != "" {
value, err := strconv.Atoi(days)
if err != nil || value < 1 || value > 90 {
+45 -4
View File
@@ -1,6 +1,7 @@
package app
import (
"database/sql"
"fmt"
"net/http"
"strings"
@@ -167,6 +168,24 @@ func (a *App) adminRefundOrder(w http.ResponseWriter, r *http.Request) {
return
}
// 代喂订单的钱有两本账:orders 和 pet_feed_tasks。任务页退款会同时记两边,
// 订单页原来只记 orders——于是先从订单页退一次,任务页还以为一分没退,
// 同一笔钱能再退一遍。这里补上另一边,并且沿用任务页那道门槛。
var feedTaskID int64
if productType == productPetFeed {
var taskStatus string
if a.db.QueryRowContext(r.Context(), `SELECT id,status FROM pet_feed_tasks WHERE id=?`,
productID).Scan(&feedTaskID, &taskStatus) != nil {
fail(w, http.StatusNotFound, 30001, "对应的代喂任务不存在")
return
}
if feedStatusSettledOrArbitrated(taskStatus) {
fail(w, http.StatusBadRequest, 20001,
"这笔代喂已经结算,报酬在喂养者账上。要退款请到「上门代喂」页面操作,那里才能确认由平台承担")
return
}
}
live := a.configPlain(r.Context(), "payment.mode", "sandbox") == "live"
channel := "gateway"
if req.Offline || providerOrderNo == "" || strings.HasPrefix(providerOrderNo, "sandbox:") || !live {
@@ -197,10 +216,32 @@ func (a *App) adminRefundOrder(w http.ResponseWriter, r *http.Request) {
if full {
newStatus = "REFUNDED"
}
if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET refunded_cent=refunded_cent+?,status=? WHERE id=?`,
refundAmount, newStatus, id); err != nil {
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
return
// 代喂订单的这一笔由 recordFeedRefund 统一处理,这里只管其它商品,
// 否则同一笔金额会被加两次。
if feedTaskID == 0 {
if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET refunded_cent=refunded_cent+?,status=? WHERE id=?`,
refundAmount, newStatus, id); err != nil {
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
return
}
} else if newStatus != status {
if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET status=? WHERE id=?`, newStatus, id); err != nil {
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
return
}
}
if feedTaskID > 0 {
// 和代喂任务页共用同一个记账函数:任务已退金额、退款流水、任务状态
// 全部在那里一起处理,这边不再各写一份。
if err = recordFeedRefund(r.Context(), tx, feedRefundEntry{
TaskID: feedTaskID, OrderID: sql.NullInt64{Int64: id, Valid: true},
RefundNo: refundNo, UserID: userID, AmountCen: refundAmount,
Channel: channel, PlatformBears: false,
Reason: req.Reason, OperatorID: current(r).ID,
}); err != nil {
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
return
}
}
// 退完才收回买到的东西:半份会员仍然是会员,代喂任务同理。
if full {
+3 -3
View File
@@ -71,9 +71,9 @@ type aiModel struct {
// Provider-specific request fields, merged into the call body. This is how a
// model that thinks by default is asked not to, without a code change:
// {"thinking":{"type":"disabled"}}.
Extra map[string]any `json:"extra,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Extra map[string]any `json:"extra,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
func aiProtocolName(protocol string) string {
+60 -11
View File
@@ -259,7 +259,19 @@ func (a *App) userRoutes() []rest.Route {
{Method: http.MethodPost, Path: "/api/v1/pet/followups/:id", Handler: auth(a.submitAdoptionFollowup)},
{Method: http.MethodGet, Path: "/api/v1/pet/mating", Handler: auth(a.matingListings)},
{Method: http.MethodPost, Path: "/api/v1/pet/mating", Handler: auth(a.createMatingListing)},
{Method: http.MethodGet, Path: "/api/v1/pet/mating/:id", Handler: auth(a.matingDetail)},
{Method: http.MethodPost, Path: "/api/v1/pet/mating/:id/close", Handler: auth(a.closeMatingListing)},
{Method: http.MethodPost, Path: "/api/v1/pet/mating/:id/interest", Handler: auth(a.createMatingInterest)},
{Method: http.MethodPost, Path: "/api/v1/pet/mating-interests/:id/decide", Handler: auth(a.decideMatingInterest)},
{Method: http.MethodGet, Path: "/api/v1/pet/mating-interests", Handler: auth(a.myMatingInterests)},
{Method: http.MethodGet, Path: "/api/v1/pet/breeds", Handler: auth(a.petBreeds)},
{Method: http.MethodPost, Path: "/api/v1/pet/listing-conversation", Handler: auth(a.petListingConversation)},
{Method: http.MethodPost, Path: "/api/v1/pet/person-conversation", Handler: auth(a.petPersonConversation)},
{Method: http.MethodGet, Path: "/api/v1/pet/contacts", Handler: auth(a.petContacts)},
{Method: http.MethodPost, Path: "/api/v1/pet/contact-requests", Handler: auth(a.requestPetContact)},
{Method: http.MethodGet, Path: "/api/v1/pet/contact-requests", Handler: auth(a.petContactRequests)},
{Method: http.MethodPost, Path: "/api/v1/pet/contact-requests/:id/decide", Handler: auth(a.decidePetContact)},
{Method: http.MethodGet, Path: "/api/v1/pet/contact-requests/:id/detail", Handler: auth(a.petContactDetail)},
{Method: http.MethodGet, Path: "/api/v1/pet/addresses", Handler: auth(a.petAddresses)},
{Method: http.MethodPost, Path: "/api/v1/pet/addresses", Handler: auth(a.createPetAddress)},
{Method: http.MethodGet, Path: "/api/v1/pet/sitter", Handler: auth(a.sitterProfile)},
@@ -350,11 +362,18 @@ func (a *App) adminRoutes() []rest.Route {
{Method: http.MethodGet, Path: "/admin/v1/posts/:id", Handler: permit("content:view", a.adminPostDetail)},
{Method: http.MethodDelete, Path: "/admin/v1/posts/:id", Handler: permit("content:manage", a.adminDeletePost)},
{Method: http.MethodGet, Path: "/admin/v1/reports", Handler: permit("reports:handle", a.adminReports)},
{Method: http.MethodGet, Path: "/admin/v1/reports/:id/target", Handler: permit("reports:handle", a.adminReportTarget)},
{Method: http.MethodGet, Path: "/admin/v1/users/:id/overview", Handler: permit("users:view", a.adminUserOverview)},
{Method: http.MethodPost, Path: "/admin/v1/users/:id/notify", Handler: permit("users:manage", a.adminNotifyUser)},
{Method: http.MethodPost, Path: "/admin/v1/users/batch", Handler: permit("users:manage", a.adminBatchUsers)},
{Method: http.MethodPost, Path: "/admin/v1/wallet/withdrawals/batch", Handler: permit("finance:manage", a.adminBatchWithdrawals)},
{Method: http.MethodGet, Path: "/admin/v1/finance/operations", Handler: permit("orders:view", a.adminMoneyOperations)},
{Method: http.MethodGet, Path: "/admin/v1/finance/operations/export", Handler: permit("orders:view", a.adminExportMoneyOperations)},
{Method: http.MethodPost, Path: "/admin/v1/reports/:id/handle", Handler: permit("reports:handle", a.adminHandleReport)},
{Method: http.MethodGet, Path: "/admin/v1/risk/users", Handler: permit("risk:view", a.adminRiskUsers)},
{Method: http.MethodGet, Path: "/admin/v1/risk/events", Handler: permit("risk:view", a.adminRiskEvents)},
{Method: http.MethodPost, Path: "/admin/v1/risk/users/:id/whitelist", Handler: permit("risk:view", a.adminWhitelistRiskUser)},
{Method: http.MethodPost, Path: "/admin/v1/risk/events/:id/handle", Handler: permit("risk:view", a.adminHandleRiskEvent)},
{Method: http.MethodPost, Path: "/admin/v1/risk/users/:id/whitelist", Handler: permit("risk:manage", a.adminWhitelistRiskUser)},
{Method: http.MethodPost, Path: "/admin/v1/risk/events/:id/handle", Handler: permit("risk:manage", a.adminHandleRiskEvent)},
{Method: http.MethodPost, Path: "/admin/v1/posts/batch", Handler: permit("content:manage", a.adminBatchPosts)},
{Method: http.MethodPost, Path: "/admin/v1/messages/batch", Handler: permit("messages:manage", a.adminBatchMessages)},
{Method: http.MethodPost, Path: "/admin/v1/reports/batch", Handler: permit("reports:handle", a.adminBatchReports)},
@@ -374,7 +393,7 @@ func (a *App) adminRoutes() []rest.Route {
{Method: http.MethodDelete, Path: "/admin/v1/orders/:id", Handler: permit("orders:manage", a.adminDeleteOrder)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/pay", Handler: permit("orders:manage", a.adminMarkOrderPaid)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/close", Handler: permit("orders:manage", a.adminCloseOrder)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/refund", Handler: permit("orders:manage", a.adminRefundOrder)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/refund", Handler: permit("finance:manage", a.adminRefundOrder)},
{Method: http.MethodGet, Path: "/admin/v1/orders/:id/refunds", Handler: permit("orders:view", a.adminOrderRefunds)},
{Method: http.MethodGet, Path: "/admin/v1/finance/overview", Handler: permit("orders:view", a.adminFinanceOverview)},
{Method: http.MethodGet, Path: "/admin/v1/notifications/broadcasts", Handler: permit("users:view", a.adminBroadcasts)},
@@ -394,17 +413,37 @@ func (a *App) adminRoutes() []rest.Route {
{Method: http.MethodPut, Path: "/admin/v1/pet/listings/:id", Handler: permit("users:manage", a.adminUpdatePetListing)},
{Method: http.MethodDelete, Path: "/admin/v1/pet/listings/:id", Handler: permit("users:manage", a.adminDeletePetListing)},
{Method: http.MethodPut, Path: "/admin/v1/pet/feed-tasks/:id", Handler: permit("users:manage", a.adminUpdateFeedTask)},
{Method: http.MethodPost, Path: "/admin/v1/pet/feed-tasks/:id/refund", Handler: permit("users:manage", a.adminRefundFeedTask)},
{Method: http.MethodPost, Path: "/admin/v1/pet/feed-tasks/:id/refund", Handler: permit("finance:manage", a.adminRefundFeedTask)},
{Method: http.MethodGet, Path: "/admin/v1/pet/feed-tasks/:id/refunds", Handler: permit("users:view", a.adminFeedRefunds)},
{Method: http.MethodGet, Path: "/admin/v1/pet/overview", Handler: permit("users:view", a.adminPetOverview)},
{Method: http.MethodGet, Path: "/admin/v1/pet/sitters", Handler: permit("users:view", a.adminSitters)},
{Method: http.MethodPost, Path: "/admin/v1/pet/sitters/:id/review", Handler: permit("users:manage", a.adminReviewSitter)},
// 保证金是押在平台手里的用户的钱,动它归财务权限,不跟资质审核混在一起。
{Method: http.MethodGet, Path: "/admin/v1/pet/deposits", Handler: permit("finance:manage", a.adminDeposits)},
{Method: http.MethodGet, Path: "/admin/v1/pet/deposits/:id/entries", Handler: permit("finance:manage", a.adminDepositHistory)},
{Method: http.MethodPost, Path: "/admin/v1/pet/deposits/:id/adjust", Handler: permit("finance:manage", a.adminAdjustDeposit)},
// 一直在写、却没有任何读取路径的那几张表,补上入口。
{Method: http.MethodGet, Path: "/admin/v1/payment-events", Handler: permit("finance:view", a.adminPaymentEvents)},
{Method: http.MethodGet, Path: "/admin/v1/payment-events/:id/payload", Handler: permit("finance:view", a.adminPaymentEventPayload)},
{Method: http.MethodGet, Path: "/admin/v1/pet/feed-reviews", Handler: permit("content:view", a.adminFeedReviews)},
{Method: http.MethodPost, Path: "/admin/v1/pet/feed-reviews/:id/visibility", Handler: permit("content:manage", a.adminHideFeedReview)},
{Method: http.MethodGet, Path: "/admin/v1/pet/requests", Handler: permit("users:view", a.adminPetRequests)},
{Method: http.MethodGet, Path: "/admin/v1/users/:id/consents", Handler: permit("users:view", a.adminUserConsents)},
{Method: http.MethodGet, Path: "/admin/v1/pet/feed-tasks", Handler: permit("users:view", a.adminFeedTasks)},
{Method: http.MethodPost, Path: "/admin/v1/pet/feed-tasks/:id/escrow", Handler: permit("users:manage", a.adminEscrowFeedTask)},
{Method: http.MethodGet, Path: "/admin/v1/pet/feed-tasks/:id", Handler: permit("users:view", a.adminFeedTaskDetail)},
{Method: http.MethodPost, Path: "/admin/v1/pet/feed-tasks/:id/assign", Handler: permit("users:manage", a.adminAssignFeedTask)},
{Method: http.MethodPost, Path: "/admin/v1/pet/feed-tasks/:id/settle", Handler: permit("finance:manage", a.adminSettleFeedTask)},
{Method: http.MethodGet, Path: "/admin/v1/pet/sitter-candidates", Handler: permit("users:view", a.adminAvailableSitters)},
{Method: http.MethodPost, Path: "/admin/v1/media/upload", Handler: permit("users:manage", a.adminUploadMedia)},
{Method: http.MethodGet, Path: "/admin/v1/pet/breeds", Handler: permit("users:view", a.adminBreeds)},
{Method: http.MethodPost, Path: "/admin/v1/pet/breeds", Handler: permit("users:manage", a.adminSaveBreed)},
{Method: http.MethodDelete, Path: "/admin/v1/pet/breeds/:id", Handler: permit("users:manage", a.adminDeleteBreed)},
{Method: http.MethodGet, Path: "/admin/v1/pets/:id/relations", Handler: permit("users:view", a.adminPetRelations)},
{Method: http.MethodPost, Path: "/admin/v1/pet/feed-tasks/:id/escrow", Handler: permit("finance:manage", a.adminEscrowFeedTask)},
{Method: http.MethodGet, Path: "/admin/v1/pet/disputes", Handler: permit("users:view", a.adminFeedDisputes)},
{Method: http.MethodPost, Path: "/admin/v1/pet/disputes/:id/handle", Handler: permit("users:manage", a.adminHandleFeedDispute)},
{Method: http.MethodPost, Path: "/admin/v1/pet/disputes/:id/handle", Handler: permit("finance:manage", a.adminHandleFeedDispute)},
{Method: http.MethodGet, Path: "/admin/v1/wallet/withdrawals", Handler: permit("users:view", a.adminWithdrawals)},
{Method: http.MethodPost, Path: "/admin/v1/wallet/withdrawals/:id/handle", Handler: permit("users:manage", a.adminHandleWithdrawal)},
{Method: http.MethodPost, Path: "/admin/v1/wallet/withdrawals/:id/handle", Handler: permit("finance:manage", a.adminHandleWithdrawal)},
{Method: http.MethodGet, Path: "/admin/v1/wallet/audit", Handler: permit("users:view", a.adminWalletAudit)},
{Method: http.MethodGet, Path: "/admin/v1/chat-media", Handler: permit("messages:view", a.adminChatMedia)},
{Method: http.MethodPut, Path: "/admin/v1/chat-media/retention", Handler: permit("messages:manage", a.adminUpdateChatMediaRetention)},
@@ -476,21 +515,31 @@ func decode(r *http.Request, out any) error {
return nil
}
func pagination(r *http.Request) (int, int, int) {
// 项目里曾经有两套分页约定:一套认 size、上限 100,另一套认 pageSize、
// 上限 50。名字传错不会报错,只会静默退回每页 20 条——查不出来的那种问题。
// 现在两个参数名都认,取到哪个算哪个,上限由调用方给。
func readPage(r *http.Request, defaultSize, maxSize int) (int, int, int) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
size, _ := strconv.Atoi(r.URL.Query().Get("size"))
if size < 1 {
size, _ = strconv.Atoi(r.URL.Query().Get("pageSize"))
}
if page < 1 {
page = 1
}
if size < 1 {
size = 20
size = defaultSize
}
if size > 100 {
size = 100
if size > maxSize {
size = maxSize
}
return page, size, (page - 1) * size
}
func pagination(r *http.Request) (int, int, int) {
return readPage(r, 20, 100)
}
func pathID(r *http.Request) (int64, error) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
for i := len(parts) - 1; i >= 0; i-- {
@@ -83,19 +83,10 @@ func (a *App) reserveDailyLike(tx *sql.Tx, r *http.Request, targetType string, t
return err
}
// 客户端接口的每页上限比管理端小一些,其余行为和 pagination 完全一致:
// size 和 pageSize 两个名字都认。
func pageOptions(r *http.Request) (int, int, int) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
pageSize, _ := strconv.Atoi(r.URL.Query().Get("pageSize"))
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 50 {
pageSize = 50
}
return page, pageSize, (page - 1) * pageSize
return readPage(r, 20, 50)
}
func nullableInt64(value sql.NullInt64) any {
+2 -1
View File
@@ -164,7 +164,8 @@ type keywordRequest struct {
Status int `json:"status"`
}
var keywordScopes = map[string]bool{"all": true, "message": true, "post": true, "adoption": true, "nickname": true}
var keywordScopes = map[string]bool{"all": true, "message": true, "post": true, "adoption": true,
"mating": true, "pet": true, "nickname": true}
func validKeyword(req *keywordRequest) string {
req.Word = strings.TrimSpace(req.Word)
+75 -22
View File
@@ -26,6 +26,12 @@ type integrationFieldSpec struct {
Required bool
Options []string
Providers []string
// Scenes 为空表示所有支付场景共用;否则只在这些场景下显示。
Scenes []string
// 支付配置分两级:Section 是外层(哪一端),Channel 是内层(哪个渠道)。
// Channel 为空表示这一端里和渠道无关的设置。
Section string
Channel string
Description string
}
@@ -39,6 +45,9 @@ type integrationFieldView struct {
Required bool `json:"required"`
Options []string `json:"options,omitempty"`
Providers []string `json:"providers,omitempty"`
Scenes []string `json:"scenes,omitempty"`
Section string `json:"section,omitempty"`
Channel string `json:"channel,omitempty"`
Description string `json:"description"`
}
@@ -200,26 +209,32 @@ var integrationSpecs = map[string][]integrationFieldSpec{
{Key: "push.show_preview", Label: "通知显示消息内容", Input: "boolean", Required: true, Description: "关闭后只提示「给你发来一条消息」,不在锁屏上暴露正文"},
},
"payment": {
{Key: "payment.mode", Label: "支付模式", Input: "select", Required: true, Options: []string{"sandbox", "live"}, Description: "sandbox 可直接完成本地支付闭环"},
{Key: "payment.gateway.create_url", Label: "支付网关下单地址", Input: "text", Description: "live 模式必填,生产环境必须使用 HTTPS"},
{Key: "payment.gateway.refund_url", Label: "支付网关退款地址", Input: "text", Description: "live 模式退款必填,生产环境必须使用 HTTPS"},
{Key: "payment.gateway.token", Label: "支付网关访问令牌", Input: "secret", Description: "live 模式必填,以 Bearer Token 调用统一支付网关"},
{Key: "payment.gateway.notify_secret", Label: "支付回调签名密钥", Input: "secret", Description: "live 模式必填,至少 32 位随机字符串,用于 HMAC-SHA256 验签"},
{Key: "payment.gateway.notify_url", Label: "支付异步回调地址", Input: "text", Description: "live 模式必填,例如 https://api.example.com/api/v1/payment/notify"},
{Key: "payment.gateway.return_url", Label: "支付完成返回地址", Input: "text", Description: "H5 支付完成后返回的客户端地址"},
{Key: "payment.gateway.timeout_seconds", Label: "网关超时(秒)", Input: "number", Description: "建议 5 至 30 秒"},
{Key: "payment.alipay.enabled", Label: "启用支付宝", Input: "boolean", Required: true, Description: "控制客户端支付宝入口"},
{Key: "payment.alipay.app_id", Label: "支付宝 APPID", Input: "text", Description: "开放平台应用 APPID"},
{Key: "payment.alipay.private_key", Label: "支付宝应用私钥", Input: "secret", Description: "敏感字段加密存储"},
{Key: "payment.alipay.public_key", Label: "支付宝公钥", Input: "secret", Description: "用于验签"},
{Key: "payment.alipay.notify_url", Label: "支付宝通知地址", Input: "text", Description: "必须是公网 HTTPS 地址"},
{Key: "payment.wechat.enabled", Label: "启用微信支付", Input: "boolean", Required: true, Description: "控制客户端微信支付入口"},
{Key: "payment.wechat.app_id", Label: "微信 AppID", Input: "text", Description: "移动应用或小程序 AppID"},
{Key: "payment.wechat.mch_id", Label: "微信商户号", Input: "text", Description: "微信支付商户号"},
{Key: "payment.wechat.api_v3_key", Label: "APIv3 密钥", Input: "secret", Description: "敏感字段加密存储"},
{Key: "payment.wechat.private_key", Label: "商户私钥", Input: "secret", Description: "PEM 内容,敏感字段加密存储"},
{Key: "payment.wechat.serial_no", Label: "证书序列号", Input: "text", Description: "商户 API 证书序列号"},
{Key: "payment.wechat.notify_url", Label: "微信通知地址", Input: "text", Description: "必须是公网 HTTPS 地址"},
// 一、通用网关:和哪一端、哪个渠道都无关,钱都从这里走。
{Key: "payment.mode", Label: "支付模式", Input: "select", Required: true, Options: []string{"sandbox", "live"}, Section: "common", Description: "sandbox 可直接完成本地支付闭环,不经过真实渠道"},
{Key: "payment.gateway.create_url", Label: "支付网关下单地址", Input: "text", Section: "common", Description: "【由服务商提供】对接的聚合支付网关给的下单接口,本系统无法自动推断;必须 HTTPS"},
{Key: "payment.gateway.refund_url", Label: "支付网关退款地址", Input: "text", Section: "common", Description: "【由服务商提供】同一网关的退款接口;不填则后台退款只能走线下"},
{Key: "payment.gateway.token", Label: "支付网关访问令牌", Input: "secret", Section: "common", Description: "【由服务商提供】调用上面两个接口时用的 Bearer Token"},
{Key: "payment.gateway.notify_secret", Label: "支付回调签名密钥", Input: "secret", Section: "common", Description: "【本系统自己定】点右边「生成」得到一串随机值,把同一串配到网关那边;用于 HMAC-SHA256 验签,至少 32 位"},
{Key: "payment.gateway.notify_url", Label: "支付异步回调地址", Input: "text", Section: "common", Description: "【本系统的接口,已自动填好】把这个地址告诉网关,让它把支付结果回调到这里"},
{Key: "payment.gateway.timeout_seconds", Label: "网关超时(秒)", Input: "number", Section: "common", Description: "建议 5 至 30 秒"},
{Key: "payment.alipay.enabled", Label: "启用支付宝", Input: "boolean", Required: true, Section: "common", Channel: "alipay", Description: "总开关:关掉后三个端都不显示支付宝"},
{Key: "payment.wechat.enabled", Label: "启用微信支付", Input: "boolean", Required: true, Section: "common", Channel: "wechat", Description: "总开关:关掉后三个端都不显示微信支付"},
// 二、网页支付。
{Key: "payment.scene.h5.enabled", Label: "启用网页支付", Input: "boolean", Required: true, Scenes: []string{"h5"}, Section: "h5", Description: "关闭后网页端拿不到任何支付方式"},
{Key: "payment.gateway.return_url", Label: "支付完成返回地址", Input: "text", Scenes: []string{"h5"}, Section: "h5", Description: "【本站 H5 页面,已自动填好】付完从收银台跳回这里;只有网页支付用得上"},
{Key: "payment.alipay.h5_app_id", Label: "支付宝 APPID(网页)", Input: "text", Scenes: []string{"h5"}, Section: "h5", Channel: "alipay", Description: "【支付宝开放平台后台复制】留空则沿用下面 App 段里那个"},
{Key: "payment.wechat.h5_app_id", Label: "微信公众号 AppID", Input: "text", Scenes: []string{"h5"}, Section: "h5", Channel: "wechat", Description: "【微信公众平台后台复制】网页内微信支付(JSAPI)用;与 App、小程序的都不是同一个"},
// 三、App 支付。
{Key: "payment.scene.app.enabled", Label: "启用 App 支付", Input: "boolean", Required: true, Scenes: []string{"app"}, Section: "app", Description: "关闭后 App 端拿不到任何支付方式"},
{Key: "payment.alipay.app_app_id", Label: "支付宝 APPID(App)", Input: "text", Scenes: []string{"app"}, Section: "app", Channel: "alipay", Description: "【支付宝开放平台后台复制】多数商户和网页用同一个,填一样的即可"},
{Key: "payment.wechat.app_app_id", Label: "微信移动应用 AppID", Input: "text", Scenes: []string{"app"}, Section: "app", Channel: "wechat", Description: "【微信开放平台后台复制】「移动应用」的 AppID,须与 App 打包配置一致"},
// 四、小程序支付。
{Key: "payment.scene.mp.enabled", Label: "启用小程序支付", Input: "boolean", Required: true, Scenes: []string{"mp"}, Section: "mp", Description: "关闭后小程序端拿不到任何支付方式"},
{Key: "payment.alipay.mp_app_id", Label: "支付宝小程序 APPID", Input: "text", Scenes: []string{"mp"}, Section: "mp", Channel: "alipay", Description: "【支付宝开放平台后台复制】支付宝小程序自己的 APPID"},
{Key: "payment.wechat.mp_app_id", Label: "微信小程序 AppID", Input: "text", Scenes: []string{"mp"}, Section: "mp", Channel: "wechat", Description: "【微信公众平台-小程序后台复制】小程序自己的 AppID"},
},
}
@@ -354,10 +369,21 @@ func (a *App) adminIntegration(w http.ResponseWriter, r *http.Request) {
if group == "oauth" {
applies = false // OAuth 必填项由各渠道的启用状态独立校验。
}
if group == "payment" && len(spec.Scenes) > 0 {
// 只有真正开着的场景才参与"配置是否完整"的判断。
// 小程序没上线却因为缺小程序 AppID 报未配置,是误报。
applies = false
for _, scene := range spec.Scenes {
if a.paymentSceneEnabled(r.Context(), scene) {
applies = true
break
}
}
}
if applies && spec.Required && !hasValue {
configured = false
}
fields = append(fields, integrationFieldView{Key: spec.Key, Label: spec.Label, Value: displayValue, Input: spec.Input, Secret: secret, HasValue: hasValue, Required: spec.Required, Options: spec.Options, Providers: spec.Providers, Description: spec.Description})
fields = append(fields, integrationFieldView{Key: spec.Key, Label: spec.Label, Value: displayValue, Input: spec.Input, Secret: secret, HasValue: hasValue, Required: spec.Required, Options: spec.Options, Providers: spec.Providers, Scenes: spec.Scenes, Section: spec.Section, Channel: spec.Channel, Description: spec.Description})
}
reply(w, map[string]any{"group": group, "configured": configured, "fields": fields, "secretMask": maskedSecret})
}
@@ -515,7 +541,34 @@ func (a *App) adminTestIntegration(w http.ResponseWriter, r *http.Request) {
}
}
}
reply(w, map[string]any{"success": true, "message": "支付配置校验通过", "mode": mode, "channels": channels})
// 逐个场景报状态。"网关通了"不等于"三端都能付"——
// 微信每一端要各自的 AppID,缺哪个就说哪个,别让人自己猜。
sceneReports := []map[string]any{}
openScenes := 0
for _, scene := range paymentScenes {
enabled := a.paymentSceneEnabled(r.Context(), scene)
missing := []string{}
if enabled {
openScenes++
for _, channel := range channels {
code, _ := channel["code"].(string)
if !a.paymentSceneReady(r.Context(), code, scene) {
missing = append(missing, fmt.Sprintf("%v", channel["name"]))
}
}
}
sceneReports = append(sceneReports, map[string]any{
"scene": scene, "label": paymentSceneLabels[scene],
"enabled": enabled, "ready": enabled && len(missing) == 0,
"missingChannels": missing,
})
}
if openScenes == 0 {
fail(w, 400, 20001, "三个支付场景全部关闭,客户端拿不到任何支付方式")
return
}
reply(w, map[string]any{"success": true, "message": "支付配置校验通过", "mode": mode,
"channels": channels, "scenes": sceneReports})
case "ai":
if err := a.validateAIConfig(r.Context()); err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
+34 -4
View File
@@ -8,6 +8,7 @@ import (
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
@@ -56,7 +57,36 @@ func mediaThumbnailURL(source string) string {
return source
}
// localStorageDirectory resolves where local media actually lives.
//
// migrations/018 seeds this key as the relative path "./uploads", which only
// works when the process happens to be started from a directory that has one.
// On a release-directory layout it does not, and then every /uploads/... came
// back 404 while the files sat safely on disk. A configured directory that
// does not exist is worse than no configuration at all, so fall back to the
// absolute path the environment gave us.
func (a *App) localStorageDirectory(ctx context.Context) string {
configured := strings.TrimSpace(a.configPlain(ctx, "storage.local.directory", ""))
if configured != "" {
if info, err := os.Stat(configured); err == nil && info.IsDir() {
return configured
}
log.Printf("storage.local.directory %q is not a usable directory; falling back to %q",
configured, a.config.MediaDir)
}
return a.config.MediaDir
}
func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) {
a.uploadMediaAs(w, r, current(r).ID)
}
// uploadMediaAs is the same pipeline for both callers. It is split out because
// the console had no way to upload anything at all: every image field there was
// a text box asking an operator to paste a URL they had no way to obtain.
// Console uploads are booked against owner 0 — they belong to the platform, not
// to whichever member of staff happened to click the button.
func (a *App) uploadMediaAs(w http.ResponseWriter, r *http.Request, ownerID int64) {
// Multipart boundaries and headers add a small amount of overhead. Keep the
// actual file limit at 16 MiB without rejecting a file that is exactly at
// that limit solely because of the multipart envelope.
@@ -154,7 +184,7 @@ func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) {
}
provider := a.configPlain(r.Context(), "storage.provider", "local")
name := fmt.Sprintf("%d-%d%s", current(r).ID, time.Now().UnixNano(), extension)
name := fmt.Sprintf("%d-%d%s", ownerID, time.Now().UnixNano(), extension)
objectKey := name
if provider != "local" {
prefix := strings.Trim(a.configPlain(r.Context(), "storage.object_prefix", "media"), "/")
@@ -169,7 +199,7 @@ func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) {
var storage mediaObjectStorage
closeStorage := func() {}
if provider == "local" {
storage = localMediaStorage{directory: a.configPlain(r.Context(), "storage.local.directory", a.config.MediaDir)}
storage = localMediaStorage{directory: a.localStorageDirectory(r.Context())}
baseURL := strings.TrimSpace(a.configPlain(r.Context(), "storage.local.public_base_url", ""))
if baseURL == "" {
scheme := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0])
@@ -221,7 +251,7 @@ func (a *App) uploadMedia(w http.ResponseWriter, r *http.Request) {
}
}
result, err := a.db.ExecContext(r.Context(), `INSERT INTO media_assets(owner_user_id,media_type,storage_provider,bucket,object_key,public_url,mime_type,file_size,moderation_status,status) VALUES(?,?,?,?,?,'',?,?,1,0)`, current(r).ID, mediaType, provider, bucket, objectKey, contentType, size)
result, err := a.db.ExecContext(r.Context(), `INSERT INTO media_assets(owner_user_id,media_type,storage_provider,bucket,object_key,public_url,mime_type,file_size,moderation_status,status) VALUES(?,?,?,?,?,'',?,?,1,0)`, ownerID, mediaType, provider, bucket, objectKey, contentType, size)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "创建媒体记录失败")
return
@@ -266,6 +296,6 @@ func (a *App) serveMedia(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("X-Content-Type-Options", "nosniff")
directory := a.configPlain(r.Context(), "storage.local.directory", a.config.MediaDir)
directory := a.localStorageDirectory(r.Context())
http.ServeFile(w, r, filepath.Join(directory, filepath.Base(name)))
}
+49 -7
View File
@@ -26,8 +26,38 @@ func (a *App) availablePaymentChannels(ctx context.Context) []map[string]any {
return channels
}
// availablePaymentChannelsForScene 在渠道总开关之上再按场景过滤:
// 场景没开、或者这个场景缺自己那个 AppID,就不要把入口摆出去——
// 让用户点进去再失败,比一开始就不显示更糟。
func (a *App) availablePaymentChannelsForScene(ctx context.Context, scene string) []map[string]any {
channels := a.availablePaymentChannels(ctx)
if scene == "" {
return channels
}
filtered := []map[string]any{}
for _, channel := range channels {
code, _ := channel["code"].(string)
ready := a.paymentSceneReady(ctx, code, scene)
if !ready && !a.paymentSceneEnabled(ctx, scene) {
continue
}
if configured, ok := channel["configured"].(bool); ok {
channel["configured"] = configured && ready
}
channel["scene"] = scene
filtered = append(filtered, channel)
}
return filtered
}
func (a *App) paymentChannels(w http.ResponseWriter, r *http.Request) {
reply(w, map[string]any{"mode": a.configPlain(r.Context(), "payment.mode", "sandbox"), "items": a.availablePaymentChannels(r.Context())})
scene := requestPaymentScene(r)
reply(w, map[string]any{
"mode": a.configPlain(r.Context(), "payment.mode", "sandbox"),
"scene": scene,
"sceneEnabled": scene == "" || a.paymentSceneEnabled(r.Context(), scene),
"items": a.availablePaymentChannelsForScene(r.Context(), scene),
})
}
type planView struct {
@@ -141,15 +171,25 @@ func (a *App) payOrder(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusServiceUnavailable, 50003, "支付模式配置无效")
return
}
scene := requestPaymentScene(r)
if scene != "" && !a.paymentSceneEnabled(r.Context(), scene) {
fail(w, http.StatusServiceUnavailable, 50003, paymentSceneLabels[scene]+"当前未开放")
return
}
if mode == "live" {
var orderNo, status, channel, subject, providerOrderNo, checkoutURL string
var orderNo, status, channel, subject, providerOrderNo, checkoutURL, orderScene string
var amountCent int
var paymentPayload sql.NullString
err = a.db.QueryRowContext(r.Context(), `SELECT o.order_no,o.amount_cent,o.status,o.channel,COALESCE(p.name,'会员套餐'),o.provider_order_no,o.checkout_url,o.payment_payload FROM orders o LEFT JOIN membership_plans p ON p.id=o.product_id WHERE o.id=? AND o.user_id=? AND o.deleted_at IS NULL`, id, current(r).ID).Scan(&orderNo, &amountCent, &status, &channel, &subject, &providerOrderNo, &checkoutURL, &paymentPayload)
err = a.db.QueryRowContext(r.Context(), `SELECT o.order_no,o.amount_cent,o.status,o.channel,COALESCE(p.name,'会员套餐'),o.provider_order_no,o.checkout_url,o.payment_payload,o.payment_scene FROM orders o LEFT JOIN membership_plans p ON p.id=o.product_id WHERE o.id=? AND o.user_id=? AND o.deleted_at IS NULL`, id, current(r).ID).Scan(&orderNo, &amountCent, &status, &channel, &subject, &providerOrderNo, &checkoutURL, &paymentPayload, &orderScene)
if err != nil {
fail(w, http.StatusNotFound, 30001, "订单不存在")
return
}
if scene != "" && !a.paymentSceneReady(r.Context(), channel, scene) {
fail(w, http.StatusServiceUnavailable, 50003,
paymentSceneLabels[scene]+"尚未配置完成,请换一种支付方式")
return
}
if status == "PAID" {
reply(w, map[string]any{"success": true, "status": "PAID", "mode": mode})
return
@@ -158,7 +198,9 @@ func (a *App) payOrder(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusBadRequest, 20001, "当前订单状态无法支付")
return
}
if providerOrderNo != "" && (checkoutURL != "" || paymentPayload.Valid) {
// 同一笔订单在网页上下过单、再回 App 里付,缓存的是给浏览器用的跳转地址,
// 在 App 里唤不起来。场景不一致就重新问网关要一次。
if providerOrderNo != "" && orderScene == scene && (checkoutURL != "" || paymentPayload.Valid) {
var appPayload map[string]any
if paymentPayload.Valid && paymentPayload.String != "" {
_ = json.Unmarshal([]byte(paymentPayload.String), &appPayload)
@@ -166,7 +208,7 @@ func (a *App) payOrder(w http.ResponseWriter, r *http.Request) {
reply(w, map[string]any{"success": true, "mode": mode, "status": status, "providerOrderNo": providerOrderNo, "checkoutUrl": checkoutURL, "appPayload": appPayload})
return
}
gatewayResult, gatewayErr := a.createGatewayPayment(r.Context(), paymentGatewayOrder{OrderNo: orderNo, AmountCent: amountCent, Channel: channel, Subject: subject, UserID: current(r).ID})
gatewayResult, gatewayErr := a.createGatewayPayment(r.Context(), paymentGatewayOrder{OrderNo: orderNo, AmountCent: amountCent, Channel: channel, Subject: subject, UserID: current(r).ID, Scene: scene})
if gatewayErr != nil {
fail(w, http.StatusBadGateway, 50003, "支付网关下单失败")
return
@@ -176,11 +218,11 @@ func (a *App) payOrder(w http.ResponseWriter, r *http.Request) {
encoded, _ := json.Marshal(gatewayResult.AppPayload)
payloadJSON = string(encoded)
}
if _, err = a.db.ExecContext(r.Context(), `UPDATE orders SET provider_order_no=?,checkout_url=?,payment_payload=? WHERE id=? AND user_id=? AND status='CREATED' AND provider_order_no=''`, gatewayResult.ProviderOrderNo, gatewayResult.CheckoutURL, payloadJSON, id, current(r).ID); err != nil {
if _, err = a.db.ExecContext(r.Context(), `UPDATE orders SET provider_order_no=?,checkout_url=?,payment_payload=?,payment_scene=? WHERE id=? AND user_id=? AND status='CREATED'`, gatewayResult.ProviderOrderNo, gatewayResult.CheckoutURL, payloadJSON, scene, id, current(r).ID); err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存支付信息失败")
return
}
reply(w, map[string]any{"success": true, "mode": mode, "status": status, "providerOrderNo": gatewayResult.ProviderOrderNo, "checkoutUrl": gatewayResult.CheckoutURL, "appPayload": gatewayResult.AppPayload})
reply(w, map[string]any{"success": true, "mode": mode, "status": status, "scene": scene, "providerOrderNo": gatewayResult.ProviderOrderNo, "checkoutUrl": gatewayResult.CheckoutURL, "appPayload": gatewayResult.AppPayload})
return
}
if a.config.Environment == "production" {
+25 -12
View File
@@ -41,23 +41,22 @@ func (a *App) applyPaidOrderTx(ctx context.Context, tx *sql.Tx, orderID, userID
if owner != userID || gross != amountCent {
return fmt.Errorf("payment does not match the feeding task")
}
if status == "ESCROWED" {
if status == feedStatusEscrowed {
return nil
}
if status != "CREATED" {
if status != feedStatusCreated {
return fmt.Errorf("feeding task status does not allow payment")
}
_, err := tx.ExecContext(ctx, `UPDATE pet_feed_tasks SET status='ESCROWED',order_id=? WHERE id=? AND status='CREATED'`, orderID, productID)
return err
case productPetDeposit:
result, err := tx.ExecContext(ctx, `UPDATE pet_sitters SET deposit_cent=deposit_cent+? WHERE user_id=?`, amountCent, userID)
if err != nil {
// 走统一的记账函数:线上缴纳和客服登记必须落在同一本账上,
// 否则历史会缺一半,出了理赔说不清这笔钱是怎么来的。
if _, err := writeDepositEntry(ctx, tx, userID, int64(amountCent),
depositSourceOrder, "线上缴纳保证金", 0, orderID, 0); err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected == 0 {
return fmt.Errorf("sitter profile does not exist")
}
return nil
}
return fmt.Errorf("unknown product type: %s", productType)
@@ -81,18 +80,32 @@ func (a *App) revokePaidOrderTx(ctx context.Context, tx *sql.Tx, orderID, userID
if err := tx.QueryRowContext(ctx, `SELECT status FROM pet_feed_tasks WHERE id=? FOR UPDATE`, productID).Scan(&status); err != nil {
return fmt.Errorf("feeding task does not exist")
}
if status == "SETTLED" || status == "ARBITRATED" {
if feedStatusSettledOrArbitrated(status) {
return fmt.Errorf("feeding task is already settled; refund it through the dispute flow")
}
if status == "REFUNDED" {
if status == feedStatusRefunded {
return nil
}
_, err := tx.ExecContext(ctx, `UPDATE pet_feed_tasks SET status='REFUNDED',confirm_due_at=NULL WHERE id=?`, productID)
return err
case productPetDeposit:
// Never below zero: part of a deposit may already have paid a claim.
_, err := tx.ExecContext(ctx, `UPDATE pet_sitters SET deposit_cent=GREATEST(CAST(deposit_cent AS SIGNED)-?,0) WHERE user_id=?`, amountCent, userID)
// 退款把保证金收回来。writeDepositEntry 不允许扣成负数——
// 这笔钱可能已经赔付掉一部分,剩多少就只能收回多少。
var held int64
if err := tx.QueryRowContext(ctx, `SELECT deposit_cent FROM pet_sitters WHERE user_id=?`,
userID).Scan(&held); err != nil {
return err
}
back := int64(amountCent)
if back > held {
back = held
}
if back == 0 {
return nil
}
_, err := writeDepositEntry(ctx, tx, userID, -back, depositSourceRefund,
"保证金订单退款,收回已缴纳部分", 0, orderID, 0)
return err
}
return fmt.Errorf("unknown product type: %s", productType)
@@ -179,7 +192,7 @@ func (a *App) createDepositOrder(w http.ResponseWriter, r *http.Request) {
}
var paid int
if a.db.QueryRowContext(r.Context(), `SELECT deposit_cent FROM pet_sitters WHERE user_id=?`, current(r).ID).Scan(&paid) != nil {
fail(w, http.StatusForbidden, 30003, "请先申请成为喂养者")
failGate(w, http.StatusForbidden, 30003, "请先申请成为喂养者", gateSitter, "去申请")
return
}
required := a.configInt(r.Context(), "pet.sitter_deposit_cent", 20000)
+20 -3
View File
@@ -24,6 +24,9 @@ type paymentGatewayOrder struct {
Channel string
Subject string
UserID int64
// Scene 是 h5/app/mp。老客户端不上报时为空,此时不往网关传,
// 保持升级前的行为,不能让已经装机的版本付不了款。
Scene string
}
type paymentGatewayResult struct {
@@ -65,12 +68,26 @@ func (a *App) createGatewayPayment(ctx context.Context, order paymentGatewayOrde
if !a.paymentGatewayConfigured(ctx) {
return paymentGatewayResult{}, fmt.Errorf("payment gateway is not completely configured")
}
payload, err := json.Marshal(map[string]any{
requestBody := map[string]any{
"orderNo": order.OrderNo, "amountCent": order.AmountCent, "currency": "CNY",
"channel": order.Channel, "subject": order.Subject, "userId": order.UserID,
"notifyUrl": a.configPlain(ctx, "payment.gateway.notify_url", ""),
"returnUrl": a.configPlain(ctx, "payment.gateway.return_url", ""),
})
}
if order.Scene != "" {
// 场景决定网关该出哪种收银台:H5 给跳转地址,App 和小程序给 SDK 参数。
requestBody["scene"] = order.Scene
if appID := a.paymentSceneAppID(ctx, order.Channel, order.Scene); appID != "" {
requestBody["appId"] = appID
}
// 只有网页支付才需要"付完跳回哪里",App 和小程序回的是自己的页面。
if order.Scene == paymentSceneH5 {
requestBody["returnUrl"] = a.configPlain(ctx, "payment.gateway.return_url", "")
}
} else {
// 没上报场景的老客户端,行为和升级前完全一致。
requestBody["returnUrl"] = a.configPlain(ctx, "payment.gateway.return_url", "")
}
payload, err := json.Marshal(requestBody)
if err != nil {
return paymentGatewayResult{}, err
}
+2 -2
View File
@@ -335,12 +335,12 @@ func (a *App) adminUpdateFeedTask(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusBadRequest, 20001, "取消任务必须写明原因")
return
}
if status == "SETTLED" || status == "REFUNDED" || status == "CANCELLED" {
if status == feedStatusSettled || status == feedStatusRefunded || status == feedStatusCancelled {
fail(w, http.StatusBadRequest, 20001, "这个状态的任务不能取消")
return
}
// 钱还在托管里就先退,再取消;否则会留下一笔没有归属的钱。
if gross > refunded && status != "CREATED" {
if gross > refunded && status != feedStatusCreated {
fail(w, http.StatusBadRequest, 20001, "请先把托管中的金额退给主人,再取消任务")
return
}
+57 -7
View File
@@ -20,6 +20,7 @@ type adoptionView struct {
PetID int64 `json:"petId"`
OwnerUserID int64 `json:"ownerUserId"`
OwnerName string `json:"ownerName"`
OwnerAvatar string `json:"ownerAvatar"`
PetName string `json:"petName"`
Species string `json:"species"`
SpeciesLabel string `json:"speciesLabel"`
@@ -93,6 +94,7 @@ func (a *App) createAdoption(w http.ResponseWriter, r *http.Request) {
CityName string `json:"cityName"`
Reason string `json:"reason"`
Requirements string `json:"requirements"`
AcceptPhone bool `json:"acceptPhone"`
}
if decode(r, &req) != nil || req.PetID == 0 {
fail(w, http.StatusBadRequest, 20001, "送养信息格式错误")
@@ -133,8 +135,8 @@ func (a *App) createAdoption(w http.ResponseWriter, r *http.Request) {
return
}
reviewReason := a.adoptionReviewReason(r.Context(), current(r).ID, req.Reason+" "+req.Requirements)
result, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_adoptions(pet_id,owner_user_id,city_code,city_name,reason,requirements,review_reason)
VALUES(?,?,?,?,?,?,?)`, req.PetID, current(r).ID, strings.TrimSpace(req.CityCode), req.CityName, req.Reason, req.Requirements, reviewReason)
result, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_adoptions(pet_id,owner_user_id,city_code,city_name,reason,requirements,review_reason,accept_phone)
VALUES(?,?,?,?,?,?,?,?)`, req.PetID, current(r).ID, strings.TrimSpace(req.CityCode), req.CityName, req.Reason, req.Requirements, reviewReason, req.AcceptPhone)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "发布失败")
return
@@ -143,7 +145,7 @@ func (a *App) createAdoption(w http.ResponseWriter, r *http.Request) {
reply(w, map[string]any{"id": id, "pendingReview": true, "reviewReason": reviewReason})
}
const adoptionColumns = `a.id,a.pet_id,a.owner_user_id,owner.nickname,a.city_name,a.reason,a.requirements,a.status,a.moderation_status,a.application_count,a.created_at,
const adoptionColumns = `a.id,a.pet_id,a.owner_user_id,owner.nickname,owner.avatar_url,a.city_name,a.reason,a.requirements,a.status,a.moderation_status,a.application_count,a.created_at,
p.name,p.species,p.breed,p.gender,p.birthday,p.neutered,p.vaccinated_at,p.avatar_url`
func (a *App) scanAdoptions(ctx context.Context, rows *sql.Rows, viewer int64) []adoptionView {
@@ -155,12 +157,13 @@ func (a *App) scanAdoptions(ctx context.Context, rows *sql.Rows, viewer int64) [
var birthday, vaccinated sql.NullTime
var created time.Time
var neutered int
if rows.Scan(&item.ID, &item.PetID, &item.OwnerUserID, &item.OwnerName, &item.CityName, &item.Reason,
if rows.Scan(&item.ID, &item.PetID, &item.OwnerUserID, &item.OwnerName, &item.OwnerAvatar, &item.CityName, &item.Reason,
&item.Requirements, &item.Status, &item.Moderation, &item.Applications, &created,
&item.PetName, &item.Species, &item.Breed, &item.Gender, &birthday, &neutered, &vaccinated, &item.Avatar) != nil {
continue
}
item.SpeciesLabel = petSpecies[item.Species]
item.OwnerAvatar = avatarThumbnailURL(item.OwnerAvatar)
item.Neutered = neutered == 1
if birthday.Valid {
item.AgeMonths = monthsSince(birthday.Time)
@@ -231,6 +234,36 @@ func (a *App) adoptions(w http.ResponseWriter, r *http.Request) {
where += " AND p.species=?"
args = append(args, species)
}
if breed := strings.TrimSpace(r.URL.Query().Get("breed")); breed != "" {
where += " AND p.breed=?"
args = append(args, breed)
}
if gender := strings.TrimSpace(r.URL.Query().Get("gender")); gender == "1" || gender == "2" {
where += " AND p.gender=?"
args = append(args, gender)
}
// 年龄按月龄分档:幼年 0-11、成年 12-83、老年 84 以上。
switch strings.TrimSpace(r.URL.Query().Get("age")) {
case "baby":
where += " AND p.birthday IS NOT NULL AND TIMESTAMPDIFF(MONTH,p.birthday,CURDATE())<12"
case "adult":
where += " AND p.birthday IS NOT NULL AND TIMESTAMPDIFF(MONTH,p.birthday,CURDATE()) BETWEEN 12 AND 83"
case "senior":
where += " AND p.birthday IS NOT NULL AND TIMESTAMPDIFF(MONTH,p.birthday,CURDATE())>=84"
}
if strings.TrimSpace(r.URL.Query().Get("vaccinated")) == "1" {
where += " AND p.vaccinated_at IS NOT NULL"
}
if neutered := strings.TrimSpace(r.URL.Query().Get("neutered")); neutered == "0" || neutered == "1" {
where += " AND p.neutered=?"
args = append(args, neutered)
}
if keyword := strings.TrimSpace(r.URL.Query().Get("keyword")); keyword != "" {
where += " AND (p.name LIKE ? OR p.breed LIKE ? OR a.reason LIKE ?)"
like := "%" + keyword + "%"
args = append(args, like, like, like)
}
}
query := `SELECT ` + adoptionColumns + ` FROM pet_adoptions a
JOIN pets p ON p.id=a.pet_id JOIN user_profiles owner ON owner.user_id=a.owner_user_id` + where +
@@ -257,7 +290,9 @@ func (a *App) adoptionDetail(w http.ResponseWriter, r *http.Request) {
}
rows, queryErr := a.db.QueryContext(r.Context(), `SELECT `+adoptionColumns+` FROM pet_adoptions a
JOIN pets p ON p.id=a.pet_id JOIN user_profiles owner ON owner.user_id=a.owner_user_id
WHERE a.id=? AND a.deleted_at IS NULL AND (a.owner_user_id=? OR (a.status<>0 AND a.moderation_status=1))`, id, current(r).ID)
WHERE a.id=? AND a.deleted_at IS NULL AND (a.owner_user_id=?
OR EXISTS(SELECT 1 FROM pet_adoption_applications ap WHERE ap.adoption_id=a.id AND ap.applicant_user_id=?)
OR (a.status<>0 AND a.moderation_status=1))`, id, current(r).ID, current(r).ID)
if queryErr != nil {
fail(w, http.StatusInternalServerError, 50001, "查询失败")
return
@@ -276,6 +311,9 @@ func (a *App) adoptionDetail(w http.ResponseWriter, r *http.Request) {
var note string
_ = a.db.QueryRowContext(r.Context(), `SELECT moderation_note FROM pet_adoptions WHERE id=?`, id).Scan(&note)
view["moderationNote"] = note
} else {
// 页面要据此决定画哪个按钮:申请、等回应、看号码,还是"对方不接受电话"。
view["contact"] = a.contactState(r.Context(), "adoption", id, current(r).ID, items[0].OwnerUserID)
}
reply(w, view)
}
@@ -443,8 +481,20 @@ func (a *App) decideAdoption(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
a.notifyUser(r.Context(), applicant, "system", "领养申请通过", "送养人选择了你,请尽快联系并当面确认", "adoption", id)
reply(w, map[string]bool{"success": true})
// 以前这里只发一句"请尽快联系",但产品里根本没有联系的入口。现在直接
// 把会话建好,双方进去就能说话。
conversation, convErr := a.ensureDirectConversation(r.Context(), owner, applicant)
if convErr == nil {
_, _ = a.db.ExecContext(r.Context(), `UPDATE pet_adoptions SET conversation_id=? WHERE id=?`, conversation, id)
}
notice := "送养人选择了你,已经为你们建好会话,可以直接聊接送细节"
if convErr != nil {
notice = "送养人选择了你,请在这条送养信息里联系对方"
}
a.notifyUser(r.Context(), applicant, "system", "领养申请通过", notice, "adoption", id)
a.notifyUser(r.Context(), owner, "system", "你已选定领养人",
"已经为你和领养人建好会话,交接完成后记得来做回访", "adoption", id)
reply(w, map[string]any{"success": true, "conversationId": conversation})
}
func (a *App) closeAdoption(w http.ResponseWriter, r *http.Request) {
+10 -7
View File
@@ -210,7 +210,8 @@ func (a *App) applySitter(w http.ResponseWriter, r *http.Request) {
}
// Entering someone's home is not something an anonymous account may do.
if _, verified := a.verifiedRealName(r.Context(), current(r).ID); !verified {
fail(w, http.StatusForbidden, 30003, "上门服务需要先完成实名认证")
failGate(w, http.StatusForbidden, 30003, "上门服务需要先完成实名认证",
gateVerification, "去认证")
return
}
certs, _ := json.Marshal(req.Certs)
@@ -485,7 +486,7 @@ func (a *App) feedTaskDetail(w http.ResponseWriter, r *http.Request) {
view := map[string]any{"task": task}
// The exact address and phone number are released only to the person who has
// to go there, and only once they have been chosen.
if task.IsSitter && (task.Status == "ASSIGNED" || task.Status == "SERVING" || task.Status == "COMPLETED") {
if task.IsSitter && (task.Status == feedStatusAssigned || task.Status == feedStatusServing || task.Status == feedStatusCompleted) {
address := a.decryptAddress(r.Context(), task.ID)
view["address"] = address
}
@@ -632,15 +633,17 @@ func (a *App) applyFeedTask(w http.ResponseWriter, r *http.Request) {
var status int
var deposit int
if a.db.QueryRowContext(r.Context(), `SELECT status,deposit_cent FROM pet_sitters WHERE user_id=?`, current(r).ID).Scan(&status, &deposit) != nil {
fail(w, http.StatusForbidden, 30003, "请先申请成为喂养者")
failGate(w, http.StatusForbidden, 30003, "请先申请成为喂养者", gateSitter, "去申请")
return
}
if status != 1 {
fail(w, http.StatusForbidden, 30003, "喂养者资格审核通过后才能接单")
failGate(w, http.StatusForbidden, 30003, "喂养者资格审核通过后才能接单",
gateSitter, "看审核进度")
return
}
if required := a.configInt(r.Context(), "pet.sitter_deposit_cent", 20000); deposit < required {
fail(w, http.StatusForbidden, 30003, fmt.Sprintf("请先缴纳 %.0f 元保证金", float64(required)/100))
failGate(w, http.StatusForbidden, 30003,
fmt.Sprintf("请先缴纳 %.0f 元保证金", float64(required)/100), gateDeposit, "去缴纳")
return
}
var owner int64
@@ -653,7 +656,7 @@ func (a *App) applyFeedTask(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusBadRequest, 20001, "不能接自己发布的任务")
return
}
if taskStatus != "ESCROWED" {
if taskStatus != feedStatusEscrowed {
fail(w, http.StatusBadRequest, 20001, "任务已经不接受报名")
return
}
@@ -699,7 +702,7 @@ func (a *App) assignFeedTask(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusNotFound, 30001, "任务不存在")
return
}
if status != "ESCROWED" {
if status != feedStatusEscrowed {
fail(w, http.StatusBadRequest, 20001, "任务状态不允许选定")
return
}
+34 -8
View File
@@ -22,6 +22,9 @@ func (a *App) adminSitters(w http.ResponseWriter, r *http.Request) {
where += " AND (profile.nickname LIKE ? OR s.user_id=?)"
args = append(args, "%"+keyword+"%", keyword)
}
var total int64
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM pet_sitters s
JOIN user_profiles profile ON profile.user_id=s.user_id`+where, args...).Scan(&total)
rows, err := a.db.QueryContext(r.Context(), `SELECT s.user_id,profile.nickname,profile.avatar_url,s.city_name,s.radius_m,s.intro,s.certs_json,
s.deposit_cent,s.rating_total,s.rating_count,s.completed_count,s.cancelled_count,s.status,s.status_note,s.created_at
FROM pet_sitters s JOIN user_profiles profile ON profile.user_id=s.user_id`+where+` ORDER BY s.status ASC,s.user_id DESC LIMIT ? OFFSET ?`,
@@ -53,7 +56,7 @@ func (a *App) adminSitters(w http.ResponseWriter, r *http.Request) {
"status": status, "statusNote": note, "createdAt": created.Format(time.RFC3339),
})
}
reply(w, map[string]any{"items": items})
reply(w, map[string]any{"items": items, "total": total})
}
func (a *App) adminReviewSitter(w http.ResponseWriter, r *http.Request) {
@@ -116,9 +119,23 @@ func (a *App) adminFeedTasks(w http.ResponseWriter, r *http.Request) {
where += " AND t.city_code=?"
args = append(args, city)
}
if keyword := strings.TrimSpace(r.URL.Query().Get("keyword")); keyword != "" {
where += " AND (owner.nickname LIKE ? OR sitter.nickname LIKE ? OR p.name LIKE ? OR t.id=?)"
like := "%" + keyword + "%"
args = append(args, like, like, like, keyword)
}
// 只看需要人工处理的:没人接、超时未确认、还押着钱。
if r.URL.Query().Get("pending") == "1" {
where += " AND (t.status='ESCROWED' OR (t.status='COMPLETED' AND t.confirm_due_at<NOW()) OR t.status='DISPUTED')"
}
var total int64
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM pet_feed_tasks t JOIN pets p ON p.id=t.pet_id
JOIN pet_addresses addr ON addr.id=t.address_id JOIN user_profiles owner ON owner.user_id=t.owner_user_id
LEFT JOIN user_profiles sitter ON sitter.user_id=t.sitter_user_id`+where, args...).Scan(&total)
rows, err := a.db.QueryContext(r.Context(), `SELECT t.id,t.owner_user_id,owner.nickname,COALESCE(t.sitter_user_id,0),COALESCE(sitter.nickname,''),
p.name,addr.city_name,t.start_date,t.end_date,t.gross_cent,t.platform_fee_cent,t.payout_cent,t.status,
t.completed_visits,t.total_visits,t.confirm_due_at,t.settled_at,t.created_at
t.completed_visits,t.total_visits,t.confirm_due_at,t.settled_at,t.created_at,
t.tip_cent,t.refunded_cent,t.applications_count,COALESCE(t.conversation_id,0),t.price_cent,t.visits_per_day,t.city_code
FROM pet_feed_tasks t JOIN pets p ON p.id=t.pet_id JOIN pet_addresses addr ON addr.id=t.address_id
JOIN user_profiles owner ON owner.user_id=t.owner_user_id
LEFT JOIN user_profiles sitter ON sitter.user_id=t.sitter_user_id`+where+` ORDER BY t.id DESC LIMIT ? OFFSET ?`,
@@ -132,19 +149,24 @@ func (a *App) adminFeedTasks(w http.ResponseWriter, r *http.Request) {
for rows.Next() {
var id, ownerID, sitterID int64
var ownerName, sitterName, petName, cityName, status string
var gross, fee, payout int64
var done, total int
var gross, fee, payout, tip, refunded, price int64
var done, visits, applications, perDay int
var conversation int64
var cityCode string
var start, end, created time.Time
var confirmDue, settled sql.NullTime
if rows.Scan(&id, &ownerID, &ownerName, &sitterID, &sitterName, &petName, &cityName, &start, &end,
&gross, &fee, &payout, &status, &done, &total, &confirmDue, &settled, &created) != nil {
&gross, &fee, &payout, &status, &done, &visits, &confirmDue, &settled, &created,
&tip, &refunded, &applications, &conversation, &price, &perDay, &cityCode) != nil {
continue
}
item := map[string]any{
"id": id, "ownerUserId": ownerID, "ownerName": ownerName, "sitterUserId": sitterID, "sitterName": sitterName,
"petName": petName, "cityName": cityName, "startDate": start.Format("2006-01-02"), "endDate": end.Format("2006-01-02"),
"grossCent": gross, "platformFeeCent": fee, "payoutCent": payout, "status": status,
"completedVisits": done, "totalVisits": total, "createdAt": created.Format(time.RFC3339),
"completedVisits": done, "totalVisits": visits, "createdAt": created.Format(time.RFC3339),
"tipCent": tip, "refundedCent": refunded, "applicationsCount": applications,
"conversationId": conversation, "priceCent": price, "visitsPerDay": perDay, "cityCode": cityCode,
}
if confirmDue.Valid {
item["confirmDueAt"] = confirmDue.Time.Format(time.RFC3339)
@@ -154,7 +176,7 @@ func (a *App) adminFeedTasks(w http.ResponseWriter, r *http.Request) {
}
items = append(items, item)
}
reply(w, map[string]any{"items": items})
reply(w, map[string]any{"items": items, "total": total})
}
func (a *App) adminFeedDisputes(w http.ResponseWriter, r *http.Request) {
@@ -165,6 +187,10 @@ func (a *App) adminFeedDisputes(w http.ResponseWriter, r *http.Request) {
where = " WHERE d.status=?"
args = append(args, status)
}
var total int64
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM pet_feed_disputes d
JOIN pet_feed_tasks t ON t.id=d.task_id
JOIN user_profiles profile ON profile.user_id=d.raised_by`+where, args...).Scan(&total)
rows, err := a.db.QueryContext(r.Context(), `SELECT d.id,d.task_id,d.raised_by,profile.nickname,d.reason,d.evidence_json,d.status,d.verdict,
d.refund_cent,d.payout_cent,d.handled_note,d.handled_at,d.created_at,t.gross_cent,t.payout_cent,t.status,
t.owner_user_id,COALESCE(t.sitter_user_id,0),t.completed_visits,t.total_visits
@@ -202,7 +228,7 @@ func (a *App) adminFeedDisputes(w http.ResponseWriter, r *http.Request) {
}
items = append(items, item)
}
reply(w, map[string]any{"items": items})
reply(w, map[string]any{"items": items, "total": total})
}
// adminHandleFeedDispute is where a human decides who was right. The three
+13 -23
View File
@@ -47,6 +47,11 @@ func (a *App) adminRefundFeedTask(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusNotFound, 30001, "任务不存在")
return
}
// 有订单就以订单那本账为准:任务上那一列只是镜像,靠它算上限,
// 就是之前"同一笔钱退两次"的成因。
if actual, refundErr := feedRefundedCent(r.Context(), a.db, id); refundErr == nil {
refunded = actual
}
remaining := gross - refunded
if remaining <= 0 {
fail(w, http.StatusBadRequest, 20001, "这笔任务已经退完了")
@@ -63,12 +68,12 @@ func (a *App) adminRefundFeedTask(w http.ResponseWriter, r *http.Request) {
// 已结算意味着钱在喂养者账上,退给主人的那份只能由平台承担 —— 这是一次
// 支出决定,不能顺手做掉,所以要显式勾选。
settled := status == "SETTLED" || status == "ARBITRATED"
settled := feedStatusSettledOrArbitrated(status)
if settled && !req.PlatformBears {
fail(w, http.StatusBadRequest, 20001, "任务已结算,报酬在喂养者账上。要退给主人只能由平台承担,请勾选后再提交")
return
}
if status == "CREATED" {
if status == feedStatusCreated {
fail(w, http.StatusBadRequest, 20001, "任务还没有支付,直接取消即可")
return
}
@@ -101,30 +106,15 @@ func (a *App) adminRefundFeedTask(w http.ResponseWriter, r *http.Request) {
return
}
defer func() { _ = tx.Rollback() }()
if _, err = tx.ExecContext(r.Context(), `INSERT INTO pet_feed_refunds
(task_id,order_id,refund_no,user_id,amount_cent,channel,platform_bears,reason,handled_by)
VALUES(?,?,?,?,?,?,?,?,?)`, id, orderID, refundNo, owner, amount, channel, settled, req.Reason, current(r).ID); err != nil {
// 记账只有这一个入口,两条退款路径共用,免得再出现一边记一边不记。
if err = recordFeedRefund(r.Context(), tx, feedRefundEntry{
TaskID: id, OrderID: orderID, RefundNo: refundNo, UserID: owner,
AmountCen: amount, Channel: channel, PlatformBears: settled,
Reason: req.Reason, OperatorID: current(r).ID,
}); err != nil {
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
return
}
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET refunded_cent=refunded_cent+? WHERE id=?`, amount, id); err != nil {
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
return
}
if orderID.Valid && orderID.Int64 > 0 {
if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET refunded_cent=refunded_cent+?,
status=IF(refunded_cent+?>=amount_cent,'REFUNDED',status) WHERE id=?`, amount, amount, orderID.Int64); err != nil {
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
return
}
}
// 退完并且还没结算的任务就此关闭;部分退款保留原状态,服务还得继续。
if refunded+amount >= gross && !settled {
if _, err = tx.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status='REFUNDED',confirm_due_at=NULL WHERE id=?`, id); err != nil {
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
return
}
}
if tx.Commit() != nil {
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
return
+11 -10
View File
@@ -64,7 +64,7 @@ func (a *App) checkinFeedVisit(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusForbidden, 30003, "只有接单的喂养者可以打卡")
return
}
if status != "ASSIGNED" && status != "SERVING" {
if status != feedStatusAssigned && status != feedStatusServing {
fail(w, http.StatusBadRequest, 20001, "当前任务状态不能打卡")
return
}
@@ -280,7 +280,7 @@ func (a *App) settleFeedTask(ctx context.Context, taskID int64, reason string) e
if err = tx.QueryRowContext(ctx, `SELECT sitter_user_id,payout_cent,status FROM pet_feed_tasks WHERE id=? FOR UPDATE`, taskID).Scan(&sitter, &payout, &status); err != nil {
return err
}
if status != "COMPLETED" && status != "ARBITRATED" {
if status != feedStatusCompleted && status != feedStatusArbitrated {
return fmt.Errorf("任务状态不允许结算")
}
if !sitter.Valid || sitter.Int64 == 0 || payout <= 0 {
@@ -319,7 +319,7 @@ func (a *App) confirmFeedTask(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusForbidden, 30003, "只有发布任务的人可以确认")
return
}
if status != "COMPLETED" {
if status != feedStatusCompleted {
fail(w, http.StatusBadRequest, 20001, "任务状态不允许确认")
return
}
@@ -418,13 +418,13 @@ func (a *App) cancelFeedTask(w http.ResponseWriter, r *http.Request) {
}
// Once someone has been sent to your door, cancelling is a negotiation, not
// a button.
if status != "CREATED" && status != "ESCROWED" {
if status != feedStatusCreated && status != feedStatusEscrowed {
fail(w, http.StatusBadRequest, 20001, "已经选定喂养者的任务请通过申诉处理")
return
}
next := "CANCELLED"
if status == "ESCROWED" {
next = "REFUNDED"
next := feedStatusCancelled
if status == feedStatusEscrowed {
next = feedStatusRefunded
}
if _, err = a.db.ExecContext(r.Context(), `UPDATE pet_feed_tasks SET status=? WHERE id=? AND status=?`, next, id, status); err != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
@@ -464,7 +464,7 @@ func (a *App) raiseFeedDispute(w http.ResponseWriter, r *http.Request) {
}
// After settlement the money is already in someone's account; that is a
// refund conversation with support, not an escrow decision.
if status != "ASSIGNED" && status != "SERVING" && status != "COMPLETED" {
if status != feedStatusAssigned && status != feedStatusServing && status != feedStatusCompleted {
fail(w, http.StatusBadRequest, 20001, "当前状态不能发起申诉")
return
}
@@ -532,7 +532,7 @@ func (a *App) reviewFeedTask(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusNotFound, 30001, "任务不存在")
return
}
if status != "COMPLETED" && status != "SETTLED" && status != "ARBITRATED" {
if status != feedStatusCompleted && status != feedStatusSettled && status != feedStatusArbitrated {
fail(w, http.StatusBadRequest, 20001, "服务结束后才能评价")
return
}
@@ -594,7 +594,8 @@ func (a *App) sitterReviews(w http.ResponseWriter, r *http.Request) {
_, size, offset := pageOptions(r)
rows, queryErr := a.db.QueryContext(r.Context(), `SELECT rv.punctual,rv.accurate,rv.communication,rv.content,rv.created_at,profile.nickname,profile.avatar_url
FROM pet_feed_reviews rv JOIN user_profiles profile ON profile.user_id=rv.from_user_id
WHERE rv.to_user_id=? AND rv.visible_at IS NOT NULL ORDER BY rv.id DESC LIMIT ? OFFSET ?`, userID, size, offset)
WHERE rv.to_user_id=? AND rv.visible_at IS NOT NULL AND rv.hidden_at IS NULL
ORDER BY rv.id DESC LIMIT ? OFFSET ?`, userID, size, offset)
if queryErr != nil {
fail(w, http.StatusInternalServerError, 50001, "查询评价失败")
return
+38 -2
View File
@@ -23,6 +23,7 @@ func (a *App) createMatingListing(w http.ResponseWriter, r *http.Request) {
CityName string `json:"cityName"`
Expectation string `json:"expectation"`
VaccineMedia string `json:"vaccineMedia"`
AcceptPhone bool `json:"acceptPhone"`
}
if decode(r, &req) != nil || req.PetID == 0 {
fail(w, http.StatusBadRequest, 20001, "配种信息格式错误")
@@ -65,8 +66,17 @@ func (a *App) createMatingListing(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusBadRequest, 20001, "这只宠物已经发布过配种信息")
return
}
result, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_mating_listings(pet_id,owner_user_id,city_code,city_name,expectation,vaccine_media)
VALUES(?,?,?,?,?,?)`, req.PetID, current(r).ID, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.CityName), req.Expectation, strings.TrimSpace(req.VaccineMedia))
// 配种的期望描述之前完全没过词库,留微信号、写买卖字眼都拦不住。
reviewReason := ""
if word, action := a.matchKeyword(r.Context(), "mating", req.Expectation); word != "" {
if action == "block" {
fail(w, http.StatusBadRequest, 20001, "内容里有不能发布的词,请修改后再试")
return
}
reviewReason = "命中敏感词:" + word
}
result, err := a.db.ExecContext(r.Context(), `INSERT INTO pet_mating_listings(pet_id,owner_user_id,city_code,city_name,expectation,vaccine_media,accept_phone,review_reason)
VALUES(?,?,?,?,?,?,?,?)`, req.PetID, current(r).ID, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.CityName), req.Expectation, strings.TrimSpace(req.VaccineMedia), req.AcceptPhone, reviewReason)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "发布失败")
return
@@ -96,6 +106,32 @@ func (a *App) matingListings(w http.ResponseWriter, r *http.Request) {
where += " AND p.species=?"
args = append(args, species)
}
if breed := strings.TrimSpace(r.URL.Query().Get("breed")); breed != "" {
where += " AND p.breed=?"
args = append(args, breed)
}
if gender := strings.TrimSpace(r.URL.Query().Get("gender")); gender == "1" || gender == "2" {
where += " AND p.gender=?"
args = append(args, gender)
}
// 年龄按月龄分档:幼年 0-11、成年 12-83、老年 84 以上。
switch strings.TrimSpace(r.URL.Query().Get("age")) {
case "baby":
where += " AND p.birthday IS NOT NULL AND TIMESTAMPDIFF(MONTH,p.birthday,CURDATE())<12"
case "adult":
where += " AND p.birthday IS NOT NULL AND TIMESTAMPDIFF(MONTH,p.birthday,CURDATE()) BETWEEN 12 AND 83"
case "senior":
where += " AND p.birthday IS NOT NULL AND TIMESTAMPDIFF(MONTH,p.birthday,CURDATE())>=84"
}
if strings.TrimSpace(r.URL.Query().Get("vaccinated")) == "1" {
where += " AND p.vaccinated_at IS NOT NULL"
}
if keyword := strings.TrimSpace(r.URL.Query().Get("keyword")); keyword != "" {
where += " AND (p.name LIKE ? OR p.breed LIKE ? OR m.expectation LIKE ?)"
like := "%" + keyword + "%"
args = append(args, like, like, like)
}
}
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,m.pet_id,m.owner_user_id,owner.nickname,m.city_name,m.expectation,m.status,m.moderation_status,m.created_at,
p.name,p.species,p.breed,p.gender,p.birthday,p.avatar_url,p.vaccinated_at
+7
View File
@@ -101,6 +101,10 @@ func (a *App) validatePet(ctx context.Context, req *petRequest, ownerID int64) e
if len([]rune(req.Breed)) > 50 || len([]rune(req.Temperament)) > 200 || len(req.ChipNo) > 40 {
return fmt.Errorf("品种、芯片号或性格描述过长")
}
// 性格备注是自由文本,之前完全没过词库,留微信号和买卖字眼都拦不住。
if word, action := a.matchKeyword(ctx, "pet", req.Name+" "+req.Temperament); word != "" && action == "block" {
return fmt.Errorf("档案里有不能发布的内容,请修改后再试")
}
if req.Gender < 0 || req.Gender > 2 {
return fmt.Errorf("性别取值无效")
}
@@ -300,6 +304,9 @@ func (a *App) updatePet(w http.ResponseWriter, r *http.Request) {
// exist even if the request dies midway.
func (a *App) writePet(r *http.Request, id int64, req petRequest) (int64, error) {
birthday, vaccinated, dewormed := req.birthday, req.vaccinated, req.dewormed
// "土猫" 和 "中华田园猫" 存成同一个词,品种才筛得动、统计才有意义。
// 认不出来的写法原样保留——那是用户自己的字,不该被我们改掉。
req.Breed = a.normaliseBreed(r.Context(), req.Species, req.Breed)
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
return 0, fmt.Errorf("保存宠物档案失败")
+11 -2
View File
@@ -786,8 +786,11 @@ func (a *App) createReport(w http.ResponseWriter, r *http.Request) {
req.TargetType = strings.ToLower(strings.TrimSpace(req.TargetType))
req.Reason = strings.ToLower(strings.TrimSpace(req.Reason))
req.Description = strings.TrimSpace(req.Description)
allowedTargets := map[string]bool{"user": true, "post": true, "comment": true, "message": true}
allowedReasons := map[string]bool{"fraud": true, "harassment": true, "pornography": true, "advertising": true, "violence": true, "minor_safety": true, "privacy": true, "other": true}
// 送养和配种是最需要举报的场景:倒卖、虐待、诈骗都发生在这儿,之前反而报不了。
allowedTargets := map[string]bool{"user": true, "post": true, "comment": true, "message": true,
"adoption": true, "mating": true}
allowedReasons := map[string]bool{"fraud": true, "harassment": true, "pornography": true, "advertising": true,
"violence": true, "minor_safety": true, "privacy": true, "animal_abuse": true, "pet_trade": true, "other": true}
if !allowedTargets[req.TargetType] || !allowedReasons[req.Reason] || len([]rune(req.Description)) > 1000 || len(req.Evidence) > 6 {
fail(w, 400, 20001, "举报类型、原因或证据无效")
return
@@ -804,6 +807,12 @@ func (a *App) createReport(w http.ResponseWriter, r *http.Request) {
_ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM post_comments c JOIN posts p ON p.id=c.post_id WHERE c.id=? AND c.status=1 AND c.user_id<>? AND p.status=1 AND (p.visibility=1 OR p.user_id=? OR (p.visibility=2 AND EXISTS(SELECT 1 FROM user_follows audience WHERE audience.user_id=? AND audience.target_user_id=p.user_id))))`, req.TargetID, current(r).ID, current(r).ID, current(r).ID).Scan(&targetExists)
case "message":
_ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM im_messages message JOIN im_conversation_members member ON member.conversation_id=message.conversation_id AND member.user_id=? AND member.status=1 WHERE message.id=? AND message.sender_id<>?)`, current(r).ID, req.TargetID, current(r).ID).Scan(&targetExists)
case "adoption":
_ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM pet_adoptions WHERE id=? AND owner_user_id<>?
AND deleted_at IS NULL AND moderation_status=1)`, req.TargetID, current(r).ID).Scan(&targetExists)
case "mating":
_ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM pet_mating_listings WHERE id=? AND owner_user_id<>?
AND deleted_at IS NULL AND moderation_status=1)`, req.TargetID, current(r).ID).Scan(&targetExists)
}
if targetExists != 1 {
fail(w, http.StatusNotFound, 30001, "举报目标不存在、不可访问或属于当前账号")
+2 -2
View File
@@ -105,7 +105,7 @@ func (a *App) validateStorageProviderConfig(ctx context.Context) error {
}
if provider == "local" {
if err := validateLocalStorageDirectory(a.configPlain(ctx, "storage.local.directory", a.config.MediaDir)); err != nil {
if err := validateLocalStorageDirectory(a.localStorageDirectory(ctx)); err != nil {
return err
}
baseURL := strings.TrimSpace(a.configPlain(ctx, "storage.local.public_base_url", ""))
@@ -234,7 +234,7 @@ func (a *App) newMediaObjectStorage(ctx context.Context) (mediaObjectStorage, fu
func (a *App) newMediaObjectStorageForProvider(ctx context.Context, provider, recordedBucket string) (mediaObjectStorage, func(), error) {
provider = strings.TrimSpace(provider)
if provider == "local" {
directory := a.configPlain(ctx, "storage.local.directory", a.config.MediaDir)
directory := a.localStorageDirectory(ctx)
if err := validateLocalStorageDirectory(directory); err != nil {
return nil, func() {}, err
}