This commit is contained in:
Your Name
2026-08-27 14:04:28 +08:00
parent f7720831be
commit 334890171e
3016 changed files with 263403 additions and 27971 deletions
@@ -0,0 +1,219 @@
package app
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
func (a *App) adminDeletePlan(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "套餐编号无效")
return
}
result, err := a.db.ExecContext(r.Context(), `UPDATE membership_plans SET status=0,deleted_at=NOW(3) WHERE id=? AND deleted_at IS NULL`, id)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "删除套餐失败")
return
}
affected, _ := result.RowsAffected()
if affected == 0 {
fail(w, http.StatusNotFound, 30001, "套餐不存在或已删除")
return
}
var subscriptions, orders int64
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM subscriptions WHERE plan_id=?`, id).Scan(&subscriptions)
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM orders WHERE product_id=?`, id).Scan(&orders)
a.audit(r, "delete", "membership_plan", id, map[string]any{"mode": "soft", "subscriptions": subscriptions, "orders": orders})
reply(w, map[string]any{"success": true, "archivedSubscriptions": subscriptions, "archivedOrders": orders})
}
type adminOrderUpdateRequest struct {
AmountCent *int `json:"amountCent"`
Channel *string `json:"channel"`
ProductID *int64 `json:"productId"`
Status *string `json:"status"`
}
func validOrderStatus(status string) bool {
return status == "CREATED" || status == "PAID" || status == "REFUNDING" || status == "REFUNDED" || status == "CLOSED"
}
func (a *App) syncEditedOrderEntitlement(r *http.Request, tx *sql.Tx, orderID, userID, oldPlanID, newPlanID int64, oldStatus, newStatus string) error {
needsRevoke := oldStatus == "PAID" && (newStatus != "PAID" || oldPlanID != newPlanID)
needsGrant := newStatus == "PAID" && (oldStatus != "PAID" || oldPlanID != newPlanID)
if needsRevoke {
if _, err := tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1 AND source IN (?,?)`, userID, fmt.Sprintf("order:%d", orderID), fmt.Sprintf("admin_order:%d", orderID)); err != nil {
return err
}
}
if needsGrant {
var durationDays int
if err := tx.QueryRowContext(r.Context(), `SELECT duration_days FROM membership_plans WHERE id=?`, newPlanID).Scan(&durationDays); err != nil {
return err
}
if _, err := tx.ExecContext(r.Context(), `INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(?,?,?,1,NOW(3),DATE_ADD(NOW(3),INTERVAL ? DAY))`, userID, newPlanID, fmt.Sprintf("admin_order:%d", orderID), durationDays); err != nil {
return err
}
}
if needsRevoke || needsGrant {
return a.recomputeMembershipTx(r.Context(), tx, userID)
}
return nil
}
func (a *App) adminUpdateOrder(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
var req adminOrderUpdateRequest
if err != nil || decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "订单信息格式无效")
return
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存订单失败")
return
}
defer func() { _ = tx.Rollback() }()
var userID, oldPlanID int64
var oldAmount int
var oldStatus, oldChannel, providerOrderNo string
if err = tx.QueryRowContext(r.Context(), `SELECT user_id,product_id,amount_cent,status,channel,provider_order_no FROM orders WHERE id=? AND deleted_at IS NULL FOR UPDATE`, id).Scan(&userID, &oldPlanID, &oldAmount, &oldStatus, &oldChannel, &providerOrderNo); err != nil {
fail(w, http.StatusNotFound, 30001, "订单不存在或已删除")
return
}
newPlanID, newAmount, newStatus, newChannel := oldPlanID, oldAmount, oldStatus, oldChannel
if req.ProductID != nil {
newPlanID = *req.ProductID
}
if req.AmountCent != nil {
newAmount = *req.AmountCent
}
if req.Status != nil {
newStatus = strings.ToUpper(strings.TrimSpace(*req.Status))
}
if req.Channel != nil {
newChannel = strings.TrimSpace(*req.Channel)
}
if newPlanID <= 0 || newAmount < 0 || !validOrderStatus(newStatus) || len(newChannel) > 30 {
fail(w, http.StatusBadRequest, 20001, "订单套餐、金额、渠道或状态无效")
return
}
if a.configPlain(r.Context(), "payment.mode", "sandbox") == "live" {
financialFieldsChanged := newPlanID != oldPlanID || newAmount != oldAmount || newChannel != oldChannel
if financialFieldsChanged && (oldStatus != "CREATED" || providerOrderNo != "") {
fail(w, http.StatusBadRequest, 20001, "生产订单创建支付流水后禁止修改套餐、金额或渠道")
return
}
if newStatus != oldStatus && !(oldStatus == "CREATED" && newStatus == "CLOSED") {
fail(w, http.StatusBadRequest, 20001, "生产订单的支付与退款状态只能由已验签回调更新")
return
}
}
var planExists int
planQuery := `SELECT COUNT(*) FROM membership_plans WHERE id=?`
if newStatus == "PAID" || newPlanID != oldPlanID {
planQuery += ` AND deleted_at IS NULL`
}
if err = tx.QueryRowContext(r.Context(), planQuery, newPlanID).Scan(&planExists); err != nil || planExists == 0 {
fail(w, http.StatusBadRequest, 20001, "选择的会员套餐不存在")
return
}
if err = a.syncEditedOrderEntitlement(r, tx, id, userID, oldPlanID, newPlanID, oldStatus, newStatus); err == nil {
_, err = tx.ExecContext(r.Context(), `UPDATE orders SET product_id=?,amount_cent=?,status=?,channel=?,paid_at=IF(?='PAID',COALESCE(paid_at,NOW(3)),paid_at) WHERE id=?`, newPlanID, newAmount, newStatus, newChannel, newStatus, id)
}
if err != nil || tx.Commit() != nil {
fail(w, http.StatusInternalServerError, 50001, "保存订单及会员权益失败")
return
}
a.audit(r, "update", "order", id, map[string]any{"previousStatus": oldStatus, "status": newStatus, "previousPlanId": oldPlanID, "planId": newPlanID, "amountCent": newAmount, "channel": newChannel})
reply(w, map[string]bool{"success": true})
}
func (a *App) adminDeleteOrder(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "订单编号无效")
return
}
var userID int64
var status string
if err = a.db.QueryRowContext(r.Context(), `SELECT user_id,status FROM orders WHERE id=? AND deleted_at IS NULL`, id).Scan(&userID, &status); err != nil {
fail(w, http.StatusNotFound, 30001, "订单不存在或已删除")
return
}
if status == "REFUNDING" {
fail(w, http.StatusBadRequest, 20001, "退款处理中的订单不能删除")
return
}
result, err := a.db.ExecContext(r.Context(), `UPDATE orders SET deleted_at=NOW(3) WHERE id=? AND deleted_at IS NULL`, id)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "删除订单失败")
return
}
affected, _ := result.RowsAffected()
if affected == 0 {
fail(w, http.StatusNotFound, 30001, "订单不存在或已删除")
return
}
a.audit(r, "delete", "order", id, map[string]any{"mode": "soft", "status": status, "userId": userID, "membershipPreserved": status == "PAID"})
reply(w, map[string]any{"success": true, "membershipPreserved": status == "PAID"})
}
func (a *App) adminMessages(w http.ResponseWriter, r *http.Request) {
page, size, offset := pagination(r)
keyword := strings.TrimSpace(r.URL.Query().Get("keyword"))
conversationID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("conversationId")), 10, 64)
messageType, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("type")))
where := ` WHERE 1=1`
args := []any{}
if conversationID > 0 {
where += ` AND m.conversation_id=?`
args = append(args, conversationID)
}
if messageType > 0 {
where += ` AND m.message_type=?`
args = append(args, messageType)
}
if keyword != "" {
where += ` AND (CONVERT(m.client_msg_id USING utf8mb4) LIKE ? OR CAST(m.body AS CHAR CHARACTER SET utf8mb4) LIKE ? OR sp.nickname LIKE ? OR su.public_id LIKE ? OR EXISTS (SELECT 1 FROM im_conversation_members kcm JOIN users ku ON ku.id=kcm.user_id JOIN user_profiles kp ON kp.user_id=kcm.user_id WHERE kcm.conversation_id=m.conversation_id AND kcm.user_id<>m.sender_id AND (kp.nickname LIKE ? OR ku.public_id LIKE ?)))`
like := "%" + keyword + "%"
args = append(args, like, like, like, like, like, like)
}
base := ` FROM im_messages m JOIN users su ON su.id=m.sender_id JOIN user_profiles sp ON sp.user_id=m.sender_id`
var total int64
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*)`+base+where, args...).Scan(&total); err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询消息记录失败")
return
}
queryArgs := append(append([]any{}, args...), size, offset)
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,m.conversation_id,m.seq,m.sender_id,su.public_id,sp.nickname,sp.avatar_url,m.client_msg_id,m.message_type,m.body,m.moderation_status,m.recalled_at,m.created_at,COALESCE((SELECT GROUP_CONCAT(CONCAT(kp.nickname,' (',ku.public_id,')') ORDER BY kp.nickname SEPARATOR '、') FROM im_conversation_members kcm JOIN users ku ON ku.id=kcm.user_id JOIN user_profiles kp ON kp.user_id=kcm.user_id WHERE kcm.conversation_id=m.conversation_id AND kcm.user_id<>m.sender_id),'')`+base+where+` ORDER BY m.created_at DESC,m.id DESC LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询消息记录失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id, convID, seq, senderID int64
var publicID, nickname, avatar, clientMsgID, recipients string
var typ, moderation int
var body []byte
var recalledAt sql.NullTime
var createdAt time.Time
if rows.Scan(&id, &convID, &seq, &senderID, &publicID, &nickname, &avatar, &clientMsgID, &typ, &body, &moderation, &recalledAt, &createdAt, &recipients) != nil {
continue
}
var content any
if json.Unmarshal(body, &content) != nil {
content = string(body)
}
items = append(items, map[string]any{"id": id, "conversationId": convID, "seq": seq, "senderId": senderID, "senderPublicId": publicID, "senderNickname": nickname, "senderAvatar": avatar, "recipients": recipients, "clientMsgId": clientMsgID, "type": typ, "content": content, "moderationStatus": moderation, "recalledAt": nullableTime(recalledAt), "createdAt": createdAt})
}
reply(w, pageResult{Items: items, Total: total, Page: page, Size: size})
}