From 05b96fd69c812e6d56f78d65382c9aeea730787e Mon Sep 17 00:00:00 2001 From: reputationly <197039020@qq.com> Date: Fri, 19 Jun 2026 20:24:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(enterprise):=20=E4=BC=81=E4=B8=9A=E5=AF=B9?= =?UTF-8?q?=E5=85=AC=E8=BD=AC=E8=B4=A6=E5=85=85=E5=80=BC=20+=20=E5=A2=9E?= =?UTF-8?q?=E5=80=BC=E7=A8=8E=E5=8F=91=E7=A5=A8=20+=20=E4=BC=81=E4=B8=9A?= =?UTF-8?q?=E5=AD=90=E8=B4=A6=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 对公转账充值(里程碑1):后台银行账户配置、用户提交转账凭证、管理员审核入账 - 增值税发票(里程碑2):开票申请/审核,按用户行锁串行化杜绝并发超开 - 企业子账户(里程碑3):主账户下子账户额度分配与权限隔离 - 管理员侧待审核红点提示 - 前端 classic:转账/发票/子账户页面与支付设置 --- common/money.go | 14 + constant/context_key.go | 4 + controller/bank_transfer.go | 342 ++++++++ controller/invoice.go | 327 +++++++ controller/log.go | 47 +- controller/sub_account.go | 337 ++++++++ controller/task.go | 19 +- controller/token.go | 196 ++++- controller/usedata.go | 18 +- controller/user.go | 21 + docs/enterprise-features-design.md | 815 ++++++++++++++++++ docs/enterprise-payment-channel-research.md | 192 +++++ dto/bank_transfer.go | 55 ++ dto/invoice.go | 59 ++ dto/sub_account.go | 53 ++ i18n/keys.go | 5 + i18n/locales/en.yaml | 2 + i18n/locales/zh-CN.yaml | 2 + i18n/locales/zh-TW.yaml | 2 + middleware/sub_account.go | 59 ++ model/bank_transfer.go | 351 ++++++++ model/invoice.go | 346 ++++++++ model/log.go | 20 +- model/main.go | 10 + model/sub_account.go | 337 ++++++++ model/task.go | 22 + model/token.go | 33 +- model/topup.go | 2 + model/usedata.go | 33 + model/user.go | 67 +- model/user_cache.go | 3 + model/user_enterprise.go | 10 +- model/user_kyc.go | 10 +- router/api-router.go | 124 ++- .../bank_transfer_setting.go | 30 + .../operation_setting/sub_account_setting.go | 25 + web/classic/src/App.jsx | 18 + .../src/components/dashboard/StatsCards.jsx | 8 +- .../src/components/layout/PageLayout.jsx | 15 + .../src/components/layout/SiderBar.jsx | 57 +- .../components/layout/headerbar/UserArea.jsx | 62 +- .../components/settings/PaymentSetting.jsx | 38 + .../components/settings/PersonalSetting.jsx | 29 +- .../settings/personal/cards/KYCSetting.jsx | 129 ++- .../components/table/tokens/TokensActions.jsx | 11 +- .../table/tokens/TokensColumnDefs.jsx | 7 + .../components/table/tokens/TokensTable.jsx | 10 +- .../table/users/UsersColumnDefs.jsx | 196 +++-- .../src/components/topup/BankTransferCard.jsx | 478 ++++++++++ .../src/components/topup/InvoiceCard.jsx | 416 +++++++++ web/classic/src/components/topup/index.jsx | 19 +- web/classic/src/helpers/render.jsx | 3 + .../hooks/common/useReviewPendingCounts.js | 53 ++ web/classic/src/hooks/common/useSidebar.js | 1 + web/classic/src/i18n/locales/en.json | 139 ++- web/classic/src/i18n/locales/fr.json | 139 ++- web/classic/src/i18n/locales/ja.json | 139 ++- web/classic/src/i18n/locales/ru.json | 139 ++- web/classic/src/i18n/locales/vi.json | 134 ++- web/classic/src/i18n/locales/zh-CN.json | 139 ++- web/classic/src/i18n/locales/zh-TW.json | 139 ++- web/classic/src/i18n/locales/zh.json | 104 ++- .../src/pages/BankTransfer/InvoiceTab.jsx | 502 +++++++++++ .../src/pages/BankTransfer/TransferTab.jsx | 514 +++++++++++ web/classic/src/pages/BankTransfer/index.jsx | 55 ++ web/classic/src/pages/Enterprise/index.jsx | 2 +- web/classic/src/pages/KYC/index.jsx | 2 +- .../Setting/Operation/SettingsGeneral.jsx | 58 +- .../Payment/SettingsPaymentBankTransfer.jsx | 241 ++++++ web/classic/src/pages/SubAccount/index.jsx | 736 ++++++++++++++++ 70 files changed, 8448 insertions(+), 276 deletions(-) create mode 100644 common/money.go create mode 100644 controller/bank_transfer.go create mode 100644 controller/invoice.go create mode 100644 controller/sub_account.go create mode 100644 docs/enterprise-features-design.md create mode 100644 docs/enterprise-payment-channel-research.md create mode 100644 dto/bank_transfer.go create mode 100644 dto/invoice.go create mode 100644 dto/sub_account.go create mode 100644 middleware/sub_account.go create mode 100644 model/bank_transfer.go create mode 100644 model/invoice.go create mode 100644 model/sub_account.go create mode 100644 setting/operation_setting/bank_transfer_setting.go create mode 100644 setting/operation_setting/sub_account_setting.go create mode 100644 web/classic/src/components/topup/BankTransferCard.jsx create mode 100644 web/classic/src/components/topup/InvoiceCard.jsx create mode 100644 web/classic/src/hooks/common/useReviewPendingCounts.js create mode 100644 web/classic/src/pages/BankTransfer/InvoiceTab.jsx create mode 100644 web/classic/src/pages/BankTransfer/TransferTab.jsx create mode 100644 web/classic/src/pages/BankTransfer/index.jsx create mode 100644 web/classic/src/pages/Setting/Payment/SettingsPaymentBankTransfer.jsx create mode 100644 web/classic/src/pages/SubAccount/index.jsx diff --git a/common/money.go b/common/money.go new file mode 100644 index 00000000000..3a268e20560 --- /dev/null +++ b/common/money.go @@ -0,0 +1,14 @@ +package common + +import "github.com/shopspring/decimal" + +// FenToYuan converts an amount in fen (1/100 CNY) to yuan as float64. +// +// Per docs/enterprise-features-design.md (D1): all CNY arithmetic must stay in +// integer fen; this conversion exists only for interop with legacy float fields +// (topups.money) and human-readable display. Never feed the result back into +// monetary calculations. +func FenToYuan(fen int64) float64 { + f, _ := decimal.NewFromInt(fen).Div(decimal.NewFromInt(100)).Float64() + return f +} diff --git a/constant/context_key.go b/constant/context_key.go index 0a157f68712..04469f99eb3 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -75,4 +75,8 @@ const ( // userCache.WriteContext (relay chain). Used by KYCRequired to exempt // enterprise-certified users from the forced KYC gate. ContextKeyUserEnterpriseStatus ContextKey = "user_enterprise_status" + + // Sub-account parent id — written alongside the KYC keys by + // userCache.WriteContext (relay chain). >0 表示当前用户是某企业主账户的只读子账户。 + ContextKeyUserParentId ContextKey = "user_parent_id" ) diff --git a/controller/bank_transfer.go b/controller/bank_transfer.go new file mode 100644 index 00000000000..6c8c7f6fdd2 --- /dev/null +++ b/controller/bank_transfer.go @@ -0,0 +1,342 @@ +package controller + +import ( + "encoding/base64" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/operation_setting" + "gorm.io/gorm" + + "github.com/gin-gonic/gin" +) + +// 对公转账充值(docs/enterprise-features-design.md §二)。 +// 提交/收款信息仅对已通过企业认证的用户开放;回执图片加密复用 KYC 加密 +// (图片大小常量 maxImageBase64Len / maxImageDecodedBytes 同包共享自 controller/kyc.go)。 + +// requireEnterpriseApproved 校验当前用户已通过企业认证,未通过时写 403 并返回 false。 +func requireEnterpriseApproved(c *gin.Context) bool { + userId := c.GetInt("id") + userCache, err := model.GetUserCache(userId) + if err != nil || userCache.EnterpriseStatus != model.EnterpriseStatusApproved { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "该功能仅对已通过企业认证的用户开放", + }) + return false + } + return true +} + +// ─── User-side handlers ─────────────────────────────────────────────────────── + +// GetBankTransferConfig GET /api/user/bank_transfer/config +// 未启用或未通过企业认证时只返回 enabled=false,不下发收款信息。 +func GetBankTransferConfig(c *gin.Context) { + cfg := operation_setting.GetBankTransferSetting() + if !cfg.IsAvailable() { + common.ApiSuccess(c, dto.BankTransferConfigResponse{Enabled: false}) + return + } + userId := c.GetInt("id") + userCache, err := model.GetUserCache(userId) + if err != nil || userCache.EnterpriseStatus != model.EnterpriseStatusApproved { + common.ApiSuccess(c, dto.BankTransferConfigResponse{Enabled: false}) + return + } + common.ApiSuccess(c, dto.BankTransferConfigResponse{ + Enabled: true, + CompanyName: cfg.CompanyName, + PayeeName: cfg.PayeeName, + AccountNumber: cfg.AccountNumber, + BankName: cfg.BankName, + MinAmountFen: cfg.MinAmountFen, + Tips: cfg.Tips, + }) +} + +// GetUserBankTransfers GET /api/user/bank_transfer/self +// 历史订单对本人始终可查(即使企业认证后被重置)。 +func GetUserBankTransfers(c *gin.Context) { + userId := c.GetInt("id") + pageInfo := common.GetPageQuery(c) + orders, total, err := model.GetUserBankTransferOrders(userId, pageInfo) + if err != nil { + common.ApiErrorMsg(c, "查询转账订单失败") + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": orders, + "total": total, + }) +} + +// SubmitBankTransfer POST /api/user/bank_transfer +func SubmitBankTransfer(c *gin.Context) { + cfg := operation_setting.GetBankTransferSetting() + if !cfg.IsAvailable() { + common.ApiErrorMsg(c, "对公转账功能未开启") + return + } + if !requireEnterpriseApproved(c) { + return + } + + var req dto.BankTransferSubmitRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiErrorMsg(c, "参数错误:"+err.Error()) + return + } + if cfg.MinAmountFen > 0 && req.AmountFen < cfg.MinAmountFen { + common.ApiErrorMsg(c, fmt.Sprintf("转账金额不能低于 ¥%.2f", common.FenToYuan(cfg.MinAmountFen))) + return + } + + receiptEnc, err := encryptBankTransferReceipt(req.Receipt) + if err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + + userId := c.GetInt("id") + order, err := model.CreateBankTransferOrderWithReceipt(userId, req.AmountFen, req.Remark, receiptEnc) + if err != nil { + if errors.Is(err, model.ErrBankTransferHasPending) || errors.Is(err, model.ErrBankTransferAmountTooLarge) { + common.ApiErrorMsg(c, err.Error()) + return + } + common.ApiErrorMsg(c, "提交失败,请稍后重试") + return + } + c.JSON(http.StatusCreated, gin.H{ + "success": true, + "message": "", + "data": order, + }) +} + +// CancelBankTransfer DELETE /api/user/bank_transfer/:id +func CancelBankTransfer(c *gin.Context) { + id, err := parseBankTransferId(c) + if err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + userId := c.GetInt("id") + if err := model.CancelBankTransferOrder(userId, id); err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + common.ApiSuccess(c, nil) +} + +// ─── Admin-side handlers ────────────────────────────────────────────────────── + +// AdminGetBankTransferList GET /api/user/bank_transfer/admin?status=1&keyword=xxx&page=1&page_size=20 +func AdminGetBankTransferList(c *gin.Context) { + status, _ := strconv.Atoi(c.DefaultQuery("status", "0")) + keyword := c.Query("keyword") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + rows, total, err := model.GetBankTransferList(status, keyword, page, pageSize) + if err != nil { + common.ApiErrorMsg(c, "查询失败") + return + } + + items := make([]dto.BankTransferAdminItem, 0, len(rows)) + for _, row := range rows { + items = append(items, dto.BankTransferAdminItem{ + Id: row.Id, + UserId: row.UserId, + Username: row.Username, + AmountFen: row.AmountFen, + CreditedFen: row.CreditedFen, + QuotaGranted: row.QuotaGranted, + Remark: row.Remark, + TradeNo: row.TradeNo, + Status: row.Status, + ReviewRemark: row.ReviewRemark, + RejectReason: row.RejectReason, + ReviewedBy: row.ReviewedBy, + ReviewerName: row.ReviewerName, + HasReceipt: true, // 提交时回执必传且与订单同事务写入 + SubmittedAt: row.SubmittedAt, + ReviewedAt: row.ReviewedAt, + }) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": items, + "total": total, + }) +} + +// AdminGetBankTransferReceipt GET /api/user/bank_transfer/admin/:id/receipt +// 回执含银行账号信息,每次查看写审计日志。 +func AdminGetBankTransferReceipt(c *gin.Context) { + id, err := parseBankTransferId(c) + if err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + order, err := model.GetBankTransferOrderById(id) + if err != nil { + common.ApiErrorMsg(c, "订单不存在") + return + } + receipt, err := model.GetBankTransferReceipt(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + common.ApiErrorMsg(c, "回执不存在") + return + } + common.ApiErrorMsg(c, "查询失败") + return + } + image, err := common.DecryptIDNumber(receipt.ReceiptEnc) + if err != nil { + common.ApiErrorMsg(c, "回执解密失败") + return + } + + adminId := c.GetInt("id") + model.RecordLog(adminId, model.LogTypeManage, + fmt.Sprintf("查看用户 %d 对公转账回执 [receipt] (order_id=%d, trade_no=%s)", order.UserId, order.Id, order.TradeNo)) + + common.ApiSuccess(c, dto.BankTransferReceiptResponse{ + ReceiptImage: "data:image/jpeg;base64," + image, + }) +} + +// AdminApproveBankTransfer PUT /api/user/bank_transfer/admin/:id/approve +// 请求体可带 credited_fen 修正实际到账金额,缺省按申报金额入账(D3)。 +func AdminApproveBankTransfer(c *gin.Context) { + id, err := parseBankTransferId(c) + if err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + var req dto.BankTransferApproveRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiErrorMsg(c, "参数错误:"+err.Error()) + return + } + order, err := model.GetBankTransferOrderById(id) + if err != nil { + common.ApiErrorMsg(c, "订单不存在") + return + } + + creditedFen := req.CreditedFen + if creditedFen <= 0 { + creditedFen = order.AmountFen + } + + reviewerId := c.GetInt("id") + if err := model.ApproveBankTransferOrder(id, reviewerId, creditedFen, req.ReviewRemark, c.ClientIP()); err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + + model.RecordLog(reviewerId, model.LogTypeManage, + fmt.Sprintf("审批通过用户 %d 对公转账订单 [approve] (order_id=%d, trade_no=%s, 到账 ¥%.2f)", + order.UserId, order.Id, order.TradeNo, common.FenToYuan(creditedFen))) + + common.ApiSuccess(c, nil) +} + +// AdminRejectBankTransfer PUT /api/user/bank_transfer/admin/:id/reject +func AdminRejectBankTransfer(c *gin.Context) { + id, err := parseBankTransferId(c) + if err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + var req dto.BankTransferRejectRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiErrorMsg(c, "参数错误:"+err.Error()) + return + } + order, err := model.GetBankTransferOrderById(id) + if err != nil { + common.ApiErrorMsg(c, "订单不存在") + return + } + + reviewerId := c.GetInt("id") + if err := model.RejectBankTransferOrder(id, reviewerId, req.Reason); err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + + model.RecordLog(reviewerId, model.LogTypeManage, + fmt.Sprintf("拒绝用户 %d 对公转账订单 [reject] (order_id=%d, trade_no=%s, 原因: %s)", + order.UserId, order.Id, order.TradeNo, req.Reason)) + + common.ApiSuccess(c, nil) +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +// encryptBankTransferReceipt 校验并加密回执图片(必传),返回密文。 +func encryptBankTransferReceipt(receipt string) (string, error) { + if receipt == "" { + return "", errors.New("请上传转账回执") + } + if len(receipt) > maxImageBase64Len { + return "", errors.New("回执图片过大") + } + decoded, err := base64.StdEncoding.DecodeString(receipt) + if err != nil { + return "", errors.New("回执图片格式无效") + } + if len(decoded) > maxImageDecodedBytes { + return "", errors.New("回执图片过大") + } + enc, err := common.EncryptIDNumber(receipt) + if err != nil { + return "", errors.New("回执图片处理失败") + } + return enc, nil +} + +func parseBankTransferId(c *gin.Context) (int, error) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + return 0, fmt.Errorf("无效的转账订单 ID") + } + return id, nil +} + +// GetReviewPendingCounts 返回管理员待处理审核计数(实名认证 / 企业认证 / 对公转账+发票), +// 供侧边栏与页签红点提醒。任一计数查询失败按 0 处理,红点非关键路径不阻断页面。 +func GetReviewPendingCounts(c *gin.Context) { + kyc, _ := model.CountPendingKYC() + enterprise, _ := model.CountPendingEnterprise() + transfer, _ := model.CountPendingBankTransfer() + invoice, _ := model.CountPendingInvoice() + common.ApiSuccess(c, gin.H{ + "kyc": kyc, + "enterprise": enterprise, + "bank_transfer": transfer, + "invoice": invoice, + "bank_transfer_total": transfer + invoice, // 侧边栏「对公转账」合计 + }) +} diff --git a/controller/invoice.go b/controller/invoice.go new file mode 100644 index 00000000000..108f4c6bbdf --- /dev/null +++ b/controller/invoice.go @@ -0,0 +1,327 @@ +package controller + +import ( + "encoding/base64" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "gorm.io/gorm" + + "github.com/gin-gonic/gin" +) + +// 增值税发票(docs/enterprise-features-design.md §三)。 +// 申请仅对已通过企业认证的用户开放(requireEnterpriseApproved 与对公转账共用, +// 同包定义于 controller/bank_transfer.go);文件大小复用 KYC 图片常量。 + +// invoiceAllowedFileExts 发票文件扩展名白名单(小写)。 +var invoiceAllowedFileExts = map[string]bool{ + ".pdf": true, ".jpg": true, ".jpeg": true, ".png": true, +} + +// ─── User-side handlers ─────────────────────────────────────────────────────── + +// GetInvoiceQuota GET /api/user/invoice/quota +// 返回可开票额度与抬头预填(企业认证的公司名)。 +func GetInvoiceQuota(c *gin.Context) { + if !requireEnterpriseApproved(c) { + return + } + userId := c.GetInt("id") + available, err := model.GetUserInvoiceAvailableFen(userId) + if err != nil { + common.ApiErrorMsg(c, "查询可开票额度失败") + return + } + companyName := "" + if ent, err := model.GetEnterpriseByUserId(userId); err == nil && ent != nil { + companyName = ent.CompanyName + } + resp := dto.InvoiceQuotaResponse{ + AvailableFen: available, + CompanyName: companyName, + } + // 带上上次提交的开票信息作默认值(按用户隔离、跨登录持久) + if last, err := model.GetUserLastInvoiceRequest(userId); err == nil && last != nil { + resp.LastInvoiceType = last.InvoiceType + resp.LastTitle = last.Title + resp.LastTaxNo = last.TaxNo + resp.LastEmail = last.Email + } + common.ApiSuccess(c, resp) +} + +// GetUserInvoices GET /api/user/invoice/self +// 历史申请对本人始终可查(即使企业认证后被重置)。 +func GetUserInvoices(c *gin.Context) { + userId := c.GetInt("id") + pageInfo := common.GetPageQuery(c) + invoices, total, err := model.GetUserInvoices(userId, pageInfo) + if err != nil { + common.ApiErrorMsg(c, "查询发票申请失败") + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": invoices, + "total": total, + }) +} + +// SubmitInvoice POST /api/user/invoice +func SubmitInvoice(c *gin.Context) { + if !requireEnterpriseApproved(c) { + return + } + var req dto.InvoiceSubmitRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiErrorMsg(c, "参数错误:"+err.Error()) + return + } + + userId := c.GetInt("id") + invoice, err := model.CreateInvoiceRequest(userId, req.AmountFen, req.InvoiceType, + strings.TrimSpace(req.Title), strings.TrimSpace(req.TaxNo), strings.TrimSpace(req.Email), req.Remark) + if err != nil { + if errors.Is(err, model.ErrInvoiceHasPending) || + errors.Is(err, model.ErrInvoiceQuotaExceeded) || + errors.Is(err, model.ErrInvoiceInvalidAmount) { + common.ApiErrorMsg(c, err.Error()) + return + } + common.ApiErrorMsg(c, "提交失败,请稍后重试") + return + } + c.JSON(http.StatusCreated, gin.H{ + "success": true, + "message": "", + "data": invoice, + }) +} + +// CancelInvoice DELETE /api/user/invoice/:id +func CancelInvoice(c *gin.Context) { + id, err := parseInvoiceId(c) + if err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + userId := c.GetInt("id") + if err := model.CancelInvoiceRequest(userId, id); err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + common.ApiSuccess(c, nil) +} + +// GetUserInvoiceFile GET /api/user/invoice/:id/file — 仅本人可下载已开具的发票文件。 +func GetUserInvoiceFile(c *gin.Context) { + id, err := parseInvoiceId(c) + if err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + userId := c.GetInt("id") + invoice, err := model.GetInvoiceById(id) + if err != nil || invoice.UserId != userId { + common.ApiErrorMsg(c, "发票申请不存在") + return + } + if invoice.Status != model.InvoiceStatusIssued { + common.ApiErrorMsg(c, "发票尚未开具") + return + } + file, err := model.GetInvoiceFile(id) + if err != nil { + common.ApiErrorMsg(c, "发票文件不存在") + return + } + common.ApiSuccess(c, dto.InvoiceFileResponse{ + FileName: file.FileName, + FileData: file.FileData, + }) +} + +// ─── Admin-side handlers ────────────────────────────────────────────────────── + +// AdminGetInvoiceList GET /api/user/invoice/admin?status=1&keyword=xxx&page=1&page_size=20 +func AdminGetInvoiceList(c *gin.Context) { + status, _ := strconv.Atoi(c.DefaultQuery("status", "0")) + keyword := c.Query("keyword") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + rows, total, err := model.GetInvoiceList(status, keyword, page, pageSize) + if err != nil { + common.ApiErrorMsg(c, "查询失败") + return + } + + items := make([]dto.InvoiceAdminItem, 0, len(rows)) + for _, row := range rows { + items = append(items, dto.InvoiceAdminItem{ + Id: row.Id, + UserId: row.UserId, + Username: row.Username, + AmountFen: row.AmountFen, + InvoiceType: row.InvoiceType, + Title: row.Title, + TaxNo: row.TaxNo, + Email: row.Email, + Remark: row.Remark, + Status: row.Status, + RejectReason: row.RejectReason, + ReviewedBy: row.ReviewedBy, + ReviewerName: row.ReviewerName, + SubmittedAt: row.SubmittedAt, + ReviewedAt: row.ReviewedAt, + }) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": items, + "total": total, + }) +} + +// AdminIssueInvoice PUT /api/user/invoice/admin/:id/issue — 上传发票文件并标记已开具。 +func AdminIssueInvoice(c *gin.Context) { + id, err := parseInvoiceId(c) + if err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + var req dto.InvoiceIssueRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiErrorMsg(c, "参数错误:"+err.Error()) + return + } + fileName, err := validateInvoiceFile(req.FileName, req.FileData) + if err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + + invoice, err := model.GetInvoiceById(id) + if err != nil { + common.ApiErrorMsg(c, "发票申请不存在") + return + } + + reviewerId := c.GetInt("id") + if err := model.IssueInvoice(id, reviewerId, fileName, req.FileData); err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + + model.RecordLog(reviewerId, model.LogTypeManage, + fmt.Sprintf("开具用户 %d 增值税发票 [issue] (invoice_id=%d, 金额 ¥%.2f, 抬头: %s)", + invoice.UserId, invoice.Id, common.FenToYuan(invoice.AmountFen), invoice.Title)) + + common.ApiSuccess(c, nil) +} + +// AdminRejectInvoice PUT /api/user/invoice/admin/:id/reject +func AdminRejectInvoice(c *gin.Context) { + id, err := parseInvoiceId(c) + if err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + var req dto.InvoiceRejectRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiErrorMsg(c, "参数错误:"+err.Error()) + return + } + invoice, err := model.GetInvoiceById(id) + if err != nil { + common.ApiErrorMsg(c, "发票申请不存在") + return + } + + reviewerId := c.GetInt("id") + if err := model.RejectInvoice(id, reviewerId, req.Reason); err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + + model.RecordLog(reviewerId, model.LogTypeManage, + fmt.Sprintf("拒绝用户 %d 增值税发票申请 [reject] (invoice_id=%d, 原因: %s)", + invoice.UserId, invoice.Id, req.Reason)) + + common.ApiSuccess(c, nil) +} + +// AdminGetInvoiceFile GET /api/user/invoice/admin/:id/file — 管理员查看已开具的发票文件。 +func AdminGetInvoiceFile(c *gin.Context) { + id, err := parseInvoiceId(c) + if err != nil { + common.ApiErrorMsg(c, err.Error()) + return + } + file, err := model.GetInvoiceFile(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + common.ApiErrorMsg(c, "发票文件不存在") + return + } + common.ApiErrorMsg(c, "查询失败") + return + } + common.ApiSuccess(c, dto.InvoiceFileResponse{ + FileName: file.FileName, + FileData: file.FileData, + }) +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +// validateInvoiceFile 校验扩展名白名单与大小,返回净化后的文件名。 +// 大小限制复用 KYC 图片常量(base64 ≤ 7MB / 解码 ≤ 5MB),PDF 同样适用。 +func validateInvoiceFile(fileName string, fileData string) (string, error) { + name := strings.TrimSpace(fileName) + // 去除路径成分,防目录穿越类脏数据入库 + if idx := strings.LastIndexAny(name, "/\\"); idx >= 0 { + name = name[idx+1:] + } + if name == "" { + return "", errors.New("文件名无效") + } + dot := strings.LastIndex(name, ".") + if dot < 0 || !invoiceAllowedFileExts[strings.ToLower(name[dot:])] { + return "", errors.New("仅支持 PDF/JPG/PNG 格式的发票文件") + } + if len(fileData) > maxImageBase64Len { + return "", errors.New("发票文件过大") + } + decoded, err := base64.StdEncoding.DecodeString(fileData) + if err != nil { + return "", errors.New("发票文件格式无效") + } + if len(decoded) > maxImageDecodedBytes { + return "", errors.New("发票文件过大") + } + return name, nil +} + +func parseInvoiceId(c *gin.Context) (int, error) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + return 0, fmt.Errorf("无效的发票申请 ID") + } + return id, nil +} diff --git a/controller/log.go b/controller/log.go index 3afb66a3d0c..b0e518b5743 100644 --- a/controller/log.go +++ b/controller/log.go @@ -58,7 +58,11 @@ func GetAllLogs(c *gin.Context) { func GetUserLogs(c *gin.Context) { pageInfo := common.GetPageQuery(c) - userId := c.GetInt("id") + scope, err := resolveSelfDataScope(c) + if err != nil { + common.ApiError(c, err) + return + } logType, _ := strconv.Atoi(c.Query("type")) startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) @@ -66,7 +70,14 @@ func GetUserLogs(c *gin.Context) { modelName := c.Query("model_name") group := c.Query("group") requestId := c.Query("request_id") - logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group, requestId) + // 子账户无任何绑定 key → 返回空,不查库(防把空集合当不过滤而泄漏企业全量日志)。 + if scope.emptyForSubAccount() { + pageInfo.SetTotal(0) + pageInfo.SetItems(make([]*model.Log, 0)) + common.ApiSuccess(c, pageInfo) + return + } + logs, total, err := model.GetUserLogs(scope.userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group, requestId, scope.tokenIds) if err != nil { common.ApiError(c, err) return @@ -325,7 +336,11 @@ func ExportAllLogs(c *gin.Context) { // ExportUserLogs 普通用户视角:流式 CSV 导出自己的日志。 func ExportUserLogs(c *gin.Context) { - userId := c.GetInt("id") + scope, err := resolveSelfDataScope(c) + if err != nil { + common.ApiError(c, err) + return + } logType, _ := strconv.Atoi(c.Query("type")) startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) @@ -336,7 +351,11 @@ func ExportUserLogs(c *gin.Context) { var firstBatchErr error csvStarted := streamExportCSV(c, false, func(perBatch func(logs []*model.Log) error) error { - err := model.ExportUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, group, requestId, 1000, perBatch) + // 子账户无绑定 → 导出空 CSV(仅表头),不查库、不过滤泄漏。 + if scope.emptyForSubAccount() { + return nil + } + err := model.ExportUserLogs(scope.userId, logType, startTimestamp, endTimestamp, modelName, tokenName, group, requestId, scope.tokenIds, 1000, perBatch) firstBatchErr = err return err }) @@ -394,7 +413,7 @@ func GetLogsStat(c *gin.Context) { modelName := c.Query("model_name") channelIds := parseChannelIdsQuery(c) group := c.Query("group") - stat, err := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channelIds, group) + stat, err := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channelIds, group, nil) if err != nil { common.ApiError(c, err) return @@ -413,7 +432,11 @@ func GetLogsStat(c *gin.Context) { } func GetLogsSelfStat(c *gin.Context) { - username := c.GetString("username") + scope, err := resolveSelfDataScope(c) + if err != nil { + common.ApiError(c, err) + return + } logType, _ := strconv.Atoi(c.Query("type")) startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) @@ -421,7 +444,17 @@ func GetLogsSelfStat(c *gin.Context) { modelName := c.Query("model_name") channelIds := parseChannelIdsQuery(c) group := c.Query("group") - quotaNum, err := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channelIds, group) + // 子账户无绑定 → 零统计,不查库。 + if scope.emptyForSubAccount() { + c.JSON(200, gin.H{ + "success": true, + "message": "", + "data": gin.H{"quota": 0, "rpm": 0, "tpm": 0}, + }) + return + } + // 子账户按企业主账户用户名 + 绑定 token 集合统计;普通用户 tokenIds=nil 不过滤。 + quotaNum, err := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, scope.username, tokenName, channelIds, group, scope.tokenIds) if err != nil { common.ApiError(c, err) return diff --git a/controller/sub_account.go b/controller/sub_account.go new file mode 100644 index 00000000000..655ad14ea43 --- /dev/null +++ b/controller/sub_account.go @@ -0,0 +1,337 @@ +package controller + +import ( + "net/http" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/operation_setting" + + "github.com/gin-gonic/gin" +) + +// 企业子账户管理(docs/enterprise-features-design.md 功能C)。 +// 所有接口:requireEnterpriseApproved 前置 + handler 内强校验归属(IDOR)。 +// 路由层已挂 SubAccountForbidden 防套娃;这里再以 enterprise_status==2 兜底。 + +// selfDataScope 描述「自身数据」类接口(日志/任务/看板)的有效查询范围。 +// - 普通用户:userId=自己,tokenIds=nil(不过滤),isSubAccount=false; +// - 子账户:userId=企业主账户 id,username=企业主账户用户名,tokenIds=该子账户已绑定的 key 集合。 +// +// 关键安全约束:子账户的 tokenIds 为空集合时,调用方必须短路返回空结果(emptyForSubAccount), +// 绝不能把空集合当作「不过滤」下发给 model 层——否则会泄漏企业主账户的全量数据。 +type selfDataScope struct { + userId int + username string + tokenIds []int + isSubAccount bool +} + +// emptyForSubAccount 子账户且无任何绑定 → 应直接返回空结果,不查库。 +func (s selfDataScope) emptyForSubAccount() bool { + return s.isSubAccount && len(s.tokenIds) == 0 +} + +// resolveSelfDataScope 解析当前请求者的自身数据范围。 +func resolveSelfDataScope(c *gin.Context) (selfDataScope, error) { + userId := c.GetInt("id") + cache, err := model.GetUserCache(userId) + if err != nil { + return selfDataScope{}, err + } + if cache.ParentUserId <= 0 { + return selfDataScope{userId: userId, username: cache.Username}, nil + } + parent, err := model.GetUserCache(cache.ParentUserId) + if err != nil { + return selfDataScope{}, err + } + tokenIds, err := model.GetBoundTokenIdsBySubUser(userId) + if err != nil { + return selfDataScope{}, err + } + return selfDataScope{ + userId: cache.ParentUserId, + username: parent.Username, + tokenIds: tokenIds, + isSubAccount: true, + }, nil +} + +// GetSubAccounts GET /api/user/sub_account —— 企业自己的子账户列表(含绑定数)。 +func GetSubAccounts(c *gin.Context) { + if !requireEnterpriseApproved(c) { + return + } + parentId := c.GetInt("id") + subs, err := model.GetSubAccountsByParent(parentId) + if err != nil { + common.ApiErrorMsg(c, "查询子账户失败") + return + } + counts, err := model.GetBindingCountsByParent(parentId) + if err != nil { + common.ApiErrorMsg(c, "查询绑定信息失败") + return + } + lastUsed, err := model.GetLastUsedTimesByParent(parentId) + if err != nil { + common.ApiErrorMsg(c, "查询使用时间失败") + return + } + list := make([]dto.SubAccountResponse, 0, len(subs)) + for _, s := range subs { + list = append(list, dto.SubAccountResponse{ + Id: s.Id, + Username: s.Username, + DisplayName: s.DisplayName, + Status: s.Status, + BindingCount: counts[s.Id], + CreatedAt: s.CreatedAt, + LastUsedTime: lastUsed[s.Id], + }) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "items": list, + "max_count": operation_setting.GetSubAccountMaxCount(), + }, + }) +} + +// CreateSubAccount POST /api/user/sub_account —— 创建只读子账户。 +func CreateSubAccount(c *gin.Context) { + if !requireEnterpriseApproved(c) { + return + } + parentId := c.GetInt("id") + + var req dto.CreateSubAccountRequest + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiErrorMsg(c, "请求参数错误") + return + } + req.Username = strings.TrimSpace(req.Username) + if err := common.Validate.Struct(&req); err != nil { + common.ApiErrorMsg(c, "用户名或密码格式不正确(用户名≤20位,密码8-20位)") + return + } + + // 数量上限预检(友好提示);model.CreateSubAccount 事务内会再复检防并发越限。 + count, err := model.CountSubAccountsByParent(parentId) + if err != nil { + common.ApiErrorMsg(c, "查询子账户数量失败") + return + } + if count >= int64(operation_setting.GetSubAccountMaxCount()) { + common.ApiErrorMsg(c, "子账户数量已达上限") + return + } + + // 用户名全局唯一预检(含软删用户)。 + exist, err := model.CheckUserExistOrDeleted(req.Username, "") + if err != nil { + common.ApiErrorMsg(c, "校验用户名失败,请稍后重试") + return + } + if exist { + common.ApiErrorMsg(c, "该用户名已被占用") + return + } + + sub, err := model.CreateSubAccount(parentId, req.Username, req.Password, req.DisplayName) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": dto.SubAccountResponse{ + Id: sub.Id, + Username: sub.Username, + DisplayName: sub.DisplayName, + Status: sub.Status, + CreatedAt: sub.CreatedAt, + }, + }) +} + +// ResetSubAccountPassword PUT /api/user/sub_account/:id/password +func ResetSubAccountPassword(c *gin.Context) { + if !requireEnterpriseApproved(c) { + return + } + parentId := c.GetInt("id") + subId, err := strconv.Atoi(c.Param("id")) + if err != nil || subId <= 0 { + common.ApiErrorMsg(c, "无效的子账户 id") + return + } + var req dto.ResetSubAccountPasswordRequest + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiErrorMsg(c, "请求参数错误") + return + } + if err := common.Validate.Struct(&req); err != nil { + common.ApiErrorMsg(c, "密码格式不正确(8-20位)") + return + } + // 归属校验:子账户必须隶属当前企业。 + if _, err := model.GetSubAccount(subId, parentId); err != nil { + common.ApiError(c, err) + return + } + if err := model.ResetSubAccountPassword(subId, req.Password); err != nil { + common.ApiErrorMsg(c, "重置密码失败") + return + } + common.ApiSuccess(c, nil) +} + +// SetSubAccountStatus PUT /api/user/sub_account/:id/status +func SetSubAccountStatus(c *gin.Context) { + if !requireEnterpriseApproved(c) { + return + } + parentId := c.GetInt("id") + subId, err := strconv.Atoi(c.Param("id")) + if err != nil || subId <= 0 { + common.ApiErrorMsg(c, "无效的子账户 id") + return + } + var req dto.SetSubAccountStatusRequest + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiErrorMsg(c, "请求参数错误") + return + } + if req.Status != common.UserStatusEnabled && req.Status != common.UserStatusDisabled { + common.ApiErrorMsg(c, "无效的状态值") + return + } + if _, err := model.GetSubAccount(subId, parentId); err != nil { + common.ApiError(c, err) + return + } + if err := model.SetSubAccountStatus(subId, req.Status); err != nil { + common.ApiErrorMsg(c, "更新状态失败") + return + } + common.ApiSuccess(c, nil) +} + +// DeleteSubAccount DELETE /api/user/sub_account/:id —— 名下有绑定则拒绝(绑定保护)。 +func DeleteSubAccount(c *gin.Context) { + if !requireEnterpriseApproved(c) { + return + } + parentId := c.GetInt("id") + subId, err := strconv.Atoi(c.Param("id")) + if err != nil || subId <= 0 { + common.ApiErrorMsg(c, "无效的子账户 id") + return + } + if err := model.DeleteSubAccount(subId, parentId); err != nil { + common.ApiError(c, err) + return + } + // 删除后清理该子账户名下令牌缓存(其本无自有令牌,稳妥起见仍清一次用户缓存)。 + _ = model.InvalidateUserCache(subId) + common.ApiSuccess(c, nil) +} + +// GetSubAccountBindings GET /api/user/sub_account/:id/bindings —— 某子账户已绑定的令牌列表。 +func GetSubAccountBindings(c *gin.Context) { + if !requireEnterpriseApproved(c) { + return + } + parentId := c.GetInt("id") + subId, err := strconv.Atoi(c.Param("id")) + if err != nil || subId <= 0 { + common.ApiErrorMsg(c, "无效的子账户 id") + return + } + if _, err := model.GetSubAccount(subId, parentId); err != nil { + common.ApiError(c, err) + return + } + bindings, err := model.GetBindingsBySubUser(subId) + if err != nil { + common.ApiErrorMsg(c, "查询绑定信息失败") + return + } + list := make([]dto.SubAccountBindingResponse, 0, len(bindings)) + for _, b := range bindings { + item := dto.SubAccountBindingResponse{ + Id: b.Id, + TokenId: b.TokenId, + CreatedAt: b.CreatedAt.Unix(), + } + // 令牌仍归属企业主账户;带上名称/明文 key/余额供企业核对与子账户使用(D11/D12)。 + if token, err := model.GetTokenByIds(b.TokenId, parentId); err == nil && token != nil { + item.TokenName = token.Name + item.TokenKey = token.Key + item.RemainQuota = token.RemainQuota + item.UsedQuota = token.UsedQuota + item.UnlimitedQuota = token.UnlimitedQuota + item.Status = token.Status + item.Group = token.Group + item.ExpiredTime = token.ExpiredTime + item.ModelLimitsEnabled = token.ModelLimitsEnabled + item.ModelLimits = token.ModelLimits + } + list = append(list, item) + } + common.ApiSuccess(c, list) +} + +// BindSubAccountToken POST /api/user/sub_account/:id/bind —— 绑定企业自有 key 给子账户。 +func BindSubAccountToken(c *gin.Context) { + if !requireEnterpriseApproved(c) { + return + } + parentId := c.GetInt("id") + subId, err := strconv.Atoi(c.Param("id")) + if err != nil || subId <= 0 { + common.ApiErrorMsg(c, "无效的子账户 id") + return + } + var req dto.SubAccountTokenRequest + if err := common.DecodeJson(c.Request.Body, &req); err != nil || req.TokenId <= 0 { + common.ApiErrorMsg(c, "请求参数错误") + return + } + if err := model.BindTokenToSubAccount(parentId, subId, req.TokenId); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} + +// UnbindSubAccountToken POST /api/user/sub_account/:id/unbind +func UnbindSubAccountToken(c *gin.Context) { + if !requireEnterpriseApproved(c) { + return + } + parentId := c.GetInt("id") + subId, err := strconv.Atoi(c.Param("id")) + if err != nil || subId <= 0 { + common.ApiErrorMsg(c, "无效的子账户 id") + return + } + var req dto.SubAccountTokenRequest + if err := common.DecodeJson(c.Request.Body, &req); err != nil || req.TokenId <= 0 { + common.ApiErrorMsg(c, "请求参数错误") + return + } + if err := model.UnbindTokenFromSubAccount(parentId, subId, req.TokenId); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} diff --git a/controller/task.go b/controller/task.go index eac7db153b4..4eb8bd6ec46 100644 --- a/controller/task.go +++ b/controller/task.go @@ -45,11 +45,23 @@ func GetAllTask(c *gin.Context) { func GetUserTask(c *gin.Context) { pageInfo := common.GetPageQuery(c) - userId := c.GetInt("id") + scope, err := resolveSelfDataScope(c) + if err != nil { + common.ApiError(c, err) + return + } startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + // 子账户无绑定 key → 返回空,不查库。 + if scope.emptyForSubAccount() { + pageInfo.SetTotal(0) + pageInfo.SetItems(tasksToDto(nil, false)) + common.ApiSuccess(c, pageInfo) + return + } + queryParams := model.SyncTaskQueryParams{ Platform: constant.TaskPlatform(c.Query("platform")), TaskID: c.Query("task_id"), @@ -57,10 +69,11 @@ func GetUserTask(c *gin.Context) { Action: c.Query("action"), StartTimestamp: startTimestamp, EndTimestamp: endTimestamp, + TokenIds: scope.tokenIds, // 子账户限定绑定 token;普通用户为 nil 不过滤 } - items := model.TaskGetAllUserTask(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams) - total := model.TaskCountAllUserTask(userId, queryParams) + items := model.TaskGetAllUserTask(scope.userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams) + total := model.TaskCountAllUserTask(scope.userId, queryParams) pageInfo.SetTotal(int(total)) pageInfo.SetItems(tasksToDto(items, false)) common.ApiSuccess(c, pageInfo) diff --git a/controller/token.go b/controller/token.go index d07bf89829e..ffe884149f5 100644 --- a/controller/token.go +++ b/controller/token.go @@ -32,28 +32,126 @@ func buildMaskedTokenResponses(tokens []*model.Token) []*model.Token { return maskedTokens } -func GetAllTokens(c *gin.Context) { +// tokenReadScope 统一描述令牌「只读」类接口(列表/搜索/详情/取 key)的有效查询范围。 +// - 普通用户:ownerUserId=自己,boundIds=nil(不过滤),isSubAccount=false; +// - 子账户:ownerUserId=企业主账户 id,boundIds=该子账户绑定的 token id 集合。 +// +// 绑定 key 的 tokens.user_id 始终留在企业主身上(绑定只是查看授权),故子账户的所有 +// 令牌读接口都必须切到「父 id + 绑定集合」口径——否则按子账户自身 id 查恒为空。 +// 关键安全约束同 selfDataScope:子账户 boundIds 为空集合时必须短路返回空 / 拒绝, +// 绝不能把空集合当作「不过滤」下发给 model 层。 +type tokenReadScope struct { + ownerUserId int + boundIds []int + isSubAccount bool +} + +func (s tokenReadScope) emptyForSubAccount() bool { + return s.isSubAccount && len(s.boundIds) == 0 +} + +func (s tokenReadScope) isBound(tokenId int) bool { + for _, id := range s.boundIds { + if id == tokenId { + return true + } + } + return false +} + +func resolveTokenReadScope(c *gin.Context) (tokenReadScope, error) { userId := c.GetInt("id") + cache, err := model.GetUserCache(userId) + if err != nil { + return tokenReadScope{}, err + } + if cache.ParentUserId <= 0 { + return tokenReadScope{ownerUserId: userId}, nil + } + boundIds, err := model.GetBoundTokenIdsBySubUser(userId) + if err != nil { + return tokenReadScope{}, err + } + return tokenReadScope{ownerUserId: cache.ParentUserId, boundIds: boundIds, isSubAccount: true}, nil +} + +func GetAllTokens(c *gin.Context) { pageInfo := common.GetPageQuery(c) - tokens, err := model.GetAllUserTokens(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + scope, err := resolveTokenReadScope(c) if err != nil { common.ApiError(c, err) return } - total, _ := model.CountUserTokens(userId) + + // 子账户:令牌页只读,返回「绑定的 key 列表」(脱敏;余额可见 D12;完整 key 走 + // GetTokenKey 的子账户分支按绑定授权取回 D11)。key 始终归属企业主(user_id 不变)。 + if scope.isSubAccount { + if scope.emptyForSubAccount() { + pageInfo.SetTotal(0) + pageInfo.SetItems([]*model.Token{}) + common.ApiSuccess(c, pageInfo) + return + } + tokens, err := model.GetTokensByIdsAndUser(scope.boundIds, scope.ownerUserId) + if err != nil { + common.ApiError(c, err) + return + } + // 维持分页契约:total 为全量绑定数,items 按页内存切片(绑定数通常很小)。 + // GetPageQuery 不钳位负数 p(GORM Offset/Limit 容忍负值所以上游没炸), + // 内存切片必须自防越界 panic。 + total := len(tokens) + startIdx := pageInfo.GetStartIdx() + pageSize := pageInfo.GetPageSize() + if startIdx < 0 { + startIdx = 0 + } + if pageSize < 0 { + pageSize = 0 + } + endIdx := startIdx + pageSize + if startIdx > total { + startIdx = total + } + if endIdx > total { + endIdx = total + } + pageInfo.SetTotal(total) + pageInfo.SetItems(buildMaskedTokenResponses(tokens[startIdx:endIdx])) + common.ApiSuccess(c, pageInfo) + return + } + + tokens, err := model.GetAllUserTokens(scope.ownerUserId, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + total, _ := model.CountUserTokens(scope.ownerUserId) pageInfo.SetTotal(int(total)) pageInfo.SetItems(buildMaskedTokenResponses(tokens)) common.ApiSuccess(c, pageInfo) } func SearchTokens(c *gin.Context) { - userId := c.GetInt("id") keyword := c.Query("keyword") token := c.Query("token") - pageInfo := common.GetPageQuery(c) - tokens, total, err := model.SearchUserTokens(userId, keyword, token, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + scope, err := resolveTokenReadScope(c) + if err != nil { + common.ApiError(c, err) + return + } + // 子账户无绑定 → 返回空,不查库(防空集合被当成不过滤而搜出企业全量令牌)。 + if scope.emptyForSubAccount() { + pageInfo.SetTotal(0) + pageInfo.SetItems([]*model.Token{}) + common.ApiSuccess(c, pageInfo) + return + } + // 子账户:在「父 id + 绑定集合」内搜索;普通用户 boundIds=nil 不过滤。 + tokens, total, err := model.SearchUserTokens(scope.ownerUserId, keyword, token, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), scope.boundIds) if err != nil { common.ApiError(c, err) return @@ -65,12 +163,21 @@ func SearchTokens(c *gin.Context) { func GetToken(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) - userId := c.GetInt("id") if err != nil { common.ApiError(c, err) return } - token, err := model.GetTokenByIds(id, userId) + scope, err := resolveTokenReadScope(c) + if err != nil { + common.ApiError(c, err) + return + } + // 子账户:仅允许查看绑定给自己的令牌,按企业主属主取回(未绑定一律拒绝,防越权)。 + if scope.isSubAccount && !scope.isBound(id) { + common.ApiErrorMsg(c, "无权查看该令牌") + return + } + token, err := model.GetTokenByIds(id, scope.ownerUserId) if err != nil { common.ApiError(c, err) return @@ -80,12 +187,21 @@ func GetToken(c *gin.Context) { func GetTokenKey(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) - userId := c.GetInt("id") if err != nil { common.ApiError(c, err) return } - token, err := model.GetTokenByIds(id, userId) + scope, err := resolveTokenReadScope(c) + if err != nil { + common.ApiError(c, err) + return + } + // 子账户:仅允许取回绑定给自己的 key 全文(D11),按企业主属主查询;未绑定拒绝。 + if scope.isSubAccount && !scope.isBound(id) { + common.ApiErrorMsg(c, "无权查看该令牌") + return + } + token, err := model.GetTokenByIds(id, scope.ownerUserId) if err != nil { common.ApiError(c, err) return @@ -234,6 +350,14 @@ func AddToken(c *gin.Context) { common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong) return } + // 同账户令牌名称去重(便于企业子账户按名识别绑定令牌) + if dup, err := model.IsTokenNameDuplicated(c.GetInt("id"), token.Name, 0); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "令牌名称已存在,请使用不同的名称") + return + } // 非无限额度时,检查额度值是否超出有效范围 if !token.UnlimitedQuota { if token.RemainQuota < 0 { @@ -302,6 +426,16 @@ func AddToken(c *gin.Context) { func DeleteToken(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) userId := c.GetInt("id") + // 绑定保护:已绑定子账户的令牌禁止删除,须先解绑(设计 §4.3)。 + // 仅当绑定属于当前用户时给出提示,避免泄漏他人令牌的绑定信息。 + if binding, bErr := model.GetBindingByTokenId(id); bErr == nil && binding != nil && binding.ParentUserId == userId { + subName := "" + if sub, e := model.GetUserById(binding.SubUserId, false); e == nil { + subName = sub.Username + } + common.ApiErrorMsg(c, fmt.Sprintf("该令牌已绑定子账户 %s,请先解除绑定后再删除", subName)) + return + } err := model.DeleteTokenById(id, userId) if err != nil { common.ApiError(c, err) @@ -362,6 +496,16 @@ func UpdateToken(c *gin.Context) { }) return } + // 仅当名称变化时校验同账户去重,避免历史重名令牌改其它字段时被误拦 + if token.Name != cleanToken.Name { + if dup, err := model.IsTokenNameDuplicated(userId, token.Name, cleanToken.Id); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "令牌名称已存在,请使用不同的名称") + return + } + } // If you add more fields, please also update token.Update() cleanToken.Name = token.Name cleanToken.ExpiredTime = token.ExpiredTime @@ -396,6 +540,19 @@ func DeleteTokenBatch(c *gin.Context) { return } userId := c.GetInt("id") + // 绑定保护:批量删除中若有令牌已绑定子账户,整批拒绝并提示数量(交互更清晰)。 + if bindings, bErr := model.GetBindingsByTokenIds(tokenBatch.Ids); bErr == nil { + boundCount := 0 + for _, b := range bindings { + if b.ParentUserId == userId { + boundCount++ + } + } + if boundCount > 0 { + common.ApiErrorMsg(c, fmt.Sprintf("选中的令牌中有 %d 个已绑定子账户,请先解除绑定后再删除", boundCount)) + return + } + } count, err := model.BatchDeleteTokens(tokenBatch.Ids, userId) if err != nil { common.ApiError(c, err) @@ -418,8 +575,23 @@ func GetTokenKeysBatch(c *gin.Context) { common.ApiErrorI18n(c, i18n.MsgBatchTooMany, map[string]any{"Max": 100}) return } - userId := c.GetInt("id") - tokens, err := model.GetTokenKeysByIds(tokenBatch.Ids, userId) + scope, err := resolveTokenReadScope(c) + if err != nil { + common.ApiError(c, err) + return + } + ids := tokenBatch.Ids + if scope.isSubAccount { + // 子账户:按企业主属主取 key,且仅返回绑定给自己的令牌(D11);未绑定 id 剔除,空绑定 → 空结果(§9.7-2)。 + bound := make([]int, 0, len(ids)) + for _, id := range ids { + if scope.isBound(id) { + bound = append(bound, id) + } + } + ids = bound + } + tokens, err := model.GetTokenKeysByIds(ids, scope.ownerUserId) if err != nil { common.ApiError(c, err) return diff --git a/controller/usedata.go b/controller/usedata.go index 5e194c51750..0529be6cc43 100644 --- a/controller/usedata.go +++ b/controller/usedata.go @@ -43,7 +43,11 @@ func GetQuotaDatesByUser(c *gin.Context) { } func GetUserQuotaDates(c *gin.Context) { - userId := c.GetInt("id") + scope, err := resolveSelfDataScope(c) + if err != nil { + common.ApiError(c, err) + return + } startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) // 判断时间跨度是否超过 1 个月 @@ -54,7 +58,17 @@ func GetUserQuotaDates(c *gin.Context) { }) return } - dates, err := model.GetQuotaDataByUserId(userId, startTimestamp, endTimestamp) + var dates []*model.QuotaData + if scope.isSubAccount { + // 子账户:无绑定返回空;否则从 logs 按绑定 token 集合实时聚合(不动 quota_data,设计 §4.5)。 + if scope.emptyForSubAccount() { + dates = []*model.QuotaData{} + } else { + dates, err = model.GetQuotaDataFromLogsByTokenIds(scope.userId, scope.tokenIds, startTimestamp, endTimestamp) + } + } else { + dates, err = model.GetQuotaDataByUserId(scope.userId, startTimestamp, endTimestamp) + } if err != nil { common.ApiError(c, err) return diff --git a/controller/user.go b/controller/user.go index 39eb73c4928..27bd8394b5a 100644 --- a/controller/user.go +++ b/controller/user.go @@ -113,6 +113,12 @@ func setupLogin(user *model.User, c *gin.Context) { "role": user.Role, "status": user.Status, "group": user.Group, + // 前端 userState 直接来源于本响应(localStorage 'user')。侧边栏的子账户 + // 视图覆盖与「子账户管理」入口依赖这三个字段,登录即下发,避免要等 + // 个人设置/钱包页拉 /self 才生效。 + "kyc_status": user.KycStatus, + "enterprise_status": user.EnterpriseStatus, + "parent_user_id": user.ParentUserId, }, }) } @@ -247,6 +253,8 @@ func GetAllUsers(c *gin.Context) { return } + model.FillParentUsernames(users) // 子账户行补所属企业主用户名,展示归属关系 + pageInfo.SetTotal(int(total)) pageInfo.SetItems(users) @@ -270,6 +278,8 @@ func SearchUsers(c *gin.Context) { return } + model.FillParentUsernames(users) // 子账户行补所属企业主用户名,展示归属关系 + pageInfo.SetTotal(int(total)) pageInfo.SetItems(users) common.ApiSuccess(c, pageInfo) @@ -430,6 +440,7 @@ func GetSelf(c *gin.Context) { "permissions": permissions, "kyc_status": user.KycStatus, "enterprise_status": user.EnterpriseStatus, + "parent_user_id": user.ParentUserId, } c.JSON(http.StatusOK, gin.H{ @@ -935,6 +946,12 @@ func ManageUser(c *gin.Context) { common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error())) } case "promote": + // 子账户是「隶属企业的只读视图」,恒为普通用户。提成管理员会绕过 SubAccountForbidden + // 却仍带 parent_user_id,造成可充值/建 key 但数据按子账户口径的矛盾态,故直接禁止。 + if user.ParentUserId > 0 { + common.ApiErrorMsg(c, "子账户不支持角色变更") + return + } if myRole != common.RoleRootUser { common.ApiErrorI18n(c, i18n.MsgUserAdminCannotPromote) return @@ -945,6 +962,10 @@ func ManageUser(c *gin.Context) { } user.Role = common.RoleAdminUser case "demote": + if user.ParentUserId > 0 { + common.ApiErrorMsg(c, "子账户不支持角色变更") + return + } if user.Role == common.RoleRootUser { common.ApiErrorI18n(c, i18n.MsgUserCannotDemoteRootUser) return diff --git a/docs/enterprise-features-design.md b/docs/enterprise-features-design.md new file mode 100644 index 00000000000..622415fb0b8 --- /dev/null +++ b/docs/enterprise-features-design.md @@ -0,0 +1,815 @@ +# 企业专属功能设计文档:对公转账充值 · 增值税发票 · 子账户 + +> 版本:v1.0(已评审,全部决策点于 2026-06-10 确定,见 §八) +> 适用项目:new-api +> 前置依赖:**企业认证已上线**(见 `docs/enterprise-cert-design.md`),本文所有功能仅对 `enterprise_status = 2(已通过)` 的用户开放 +> 日期:2026-06-10 +> +> **设计基线**:延续 KYC / 企业认证两期已验证的模式 —— 跨库兼容(SQLite/MySQL/PostgreSQL)、人工审核 + 审计日志、大字段图片入库、`web/classic` 前端优先、最小化触碰上游文件。凡未特别说明处,实现约定与前两期一致。 + +--- + +## 一、背景与目标 + +企业认证解决了「企业主体核验」,本期交付认证后的三项企业级权益: + +| # | 功能 | 一句话描述 | +|---|------|-----------| +| A | **对公转账充值** | 管理员配置对公收款账户;企业用户线下转账后上传回执 + 填写金额,管理员审批后入账 | +| B | **增值税发票** | 企业用户对已到账的对公转账金额申请开票,管理员人工审核并交付发票 | +| C | **子账户** | 企业账户创建只读子账户,把自己的 key 绑定给子账户;子账户只能看绑定 key 的用量数据,不能充值、不能管理令牌,key 的消耗扣企业账户的钱 | + +### 设计原则 + +- **计费链路零改动**(子账户的根基,详见 §四 4.1) +- **复用优先**:回执/发票图片加密复用 `common/kyc_crypto.go`;审批入账复用 `ManualCompleteTopUp` 的行锁 + 幂等模式;审核页复用 KYC/企业认证审核页的 `CardPro + CardTable` 模式 +- **服务端强制**:子账户的所有限制在后端中间件/查询层强制,前端隐藏只是体验优化,不是安全边界 +- **数据库兼容**:三库同时支持(CLAUDE.md Rule 2),大字段省略 `type` 标签走 longtext/text 映射(同企业认证图片表的处理) + +--- + +## 二、功能 A:对公转账充值 + +### 2.1 流程总览 + +``` +管理员(系统设置-支付设置)配置对公收款信息并启用 + │ +企业用户(钱包管理页)看到「对公转账」卡片,展示收款信息 + │ 线下完成银行转账 + ├─► 上传银行转账回执(1 张图片)+ 填写转账金额 → 提交 + │ bank_transfer_orders: status=1 待审核 + │ +管理员(对公转账审核页)查看订单 + 回执图片 + ├─► 通过:确认/修正到账金额 → 事务内给企业账户加 quota + │ + 写 topups 流水(bank_transfer)+ 写充值日志 → status=2 + └─► 拒绝:填写原因 → status=3,用户可重新提交 +``` + +### 2.2 支付设置(管理员配置) + +新增一个 JSON option(走现有 `OptionMap` 体系,落 `options` 表),在系统设置 → 支付设置中配置: + +```jsonc +// OptionMap["BankTransferSetting"] +{ + "enabled": true, + "company_name": "武汉光谷爱计算有限公司", // 公司名称 + "payee_name": "武汉光谷爱计算有限公司", // 收款单位 + "account_number":"416180100100239037", // 收款账号 + "bank_name": "兴业银行股份有限公司武汉东湖高新科技支行", // 开户行 + "min_amount": 100, // 最低单笔转账金额(元),0=不限 + "tips": "" // 卡片附加说明(如"转账请备注注册邮箱") +} +``` + +- 参照现有 `setting/payment_*.go` 的注册模式新增 `setting/payment_bank_transfer.go` +- `enabled=false` 或四要素任一为空时,用户侧卡片不展示、提交接口拒绝 +- 收款信息属于**公开信息**(用户必须看到才能转账),不加密、随用户侧接口明文下发 + +#### 配套防误操作:锁定汇率与额度展示类型(D3 附带决策,2026-06-10) + +对公转账入账与发票对账都以 `USDExchangeRate`(7.3)为换算基准,该参数一旦被改动,历史订单复算、日志人民币回显(`controller/log.go` 已有注释说明老日志 CNY 会漂移)全部失真。因此: + +- `web/classic/src/pages/Setting/Operation/SettingsGeneral.jsx` 中将 **`USDExchangeRate` 输入框**与 **`quota_display_type`(额度展示类型)选择器**置灰(disabled),旁附说明文案(如"对公转账/发票对账基准,已锁定不可修改") +- 仅做前端置灰防误操作,**后端不加拦截**——root 仍可通过 API 直改 option,保留逃生通道;若未来需要彻底锁死,再在 option 更新接口加键名黑名单 + +### 2.3 数据库设计 + +#### 新增表 `bank_transfer_orders` + +```go +// model/bank_transfer.go +const ( + BankTransferStatusPending = 1 + BankTransferStatusApproved = 2 + BankTransferStatusRejected = 3 +) + +type BankTransferOrder struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + UserId int `json:"user_id" gorm:"index;not null"` + AmountFen int64 `json:"amount_fen" gorm:"not null"` // 用户申报转账金额,单位:分 + CreditedFen int64 `json:"credited_fen" gorm:"default:0"` // 管理员确认的实际到账金额(分),审批时填 + QuotaGranted int64 `json:"quota_granted" gorm:"default:0"` // 实际入账的 quota,审批时按汇率折算后回填 + Remark string `json:"remark" gorm:"type:varchar(255)"` // 用户备注(如转账流水号) + Status int `json:"status" gorm:"type:int;not null;default:1"` + RejectReason string `json:"reject_reason,omitempty" gorm:"type:varchar(255)"` + ReviewedBy int `json:"reviewed_by,omitempty" gorm:"type:int"` + TradeNo string `json:"trade_no" gorm:"type:varchar(64);uniqueIndex"` // BT+雪花/时间戳,审批通过后同步写入 topups + SubmittedAt *time.Time `json:"submitted_at"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} +``` + +> **金额为什么用 int64 分**:`topups.money` 用的是 float64,但人民币对账场景浮点误差不可接受(发票额度是累加比较:`Σ转账 − Σ发票`,float64 累加会出现 `99999.98999...` 这类误差,导致合法开票申请被误拒或多放额度,且发票金额必须与到账金额分毫不差)。新表统一用「分」存整数,与支付行业惯例(支付宝/微信/Stripe 均用最小货币单位整数)一致。【D1 已决策(2026-06-10):方案 a】 +> +> **金额单位实现规约**(D1 决策附带约束,实现与评审时强制执行): +> 1. **命名**:所有以「分」为单位的字段/变量一律带 `Fen` 后缀(`AmountFen` / `CreditedFen`);不带后缀的金额一律视为「元」。 +> 2. **换算收敛**:新增统一换算函数(如 `common/money.go` 的 `FenToYuan` / `YuanToFen`,含四舍五入语义),全项目禁止手写 `/100`、`*100`。 +> 3. **前端输入**:用户输入的元金额按**字符串**解析(元、分两段拆分)转分,禁止 `输入值 × 100` 的浮点乘法(JS 中 `1234.56 × 100 = 123455.99999...`)。 +> 4. **边界清单**:「分」只活在对公转账/发票闭环内部,与旧体系的接触点仅 4 处且单向——审批写 `topups` 流水(分→元)、折算 quota(分→quota)、前端展示(分→元)、用户输入(元→分)。发票额度计算全程整数运算,不读取任何 float 字段。 + +#### 新增表 `bank_transfer_receipts`(回执图片,1:1) + +```go +type BankTransferReceipt struct { + Id int `gorm:"primaryKey;autoIncrement"` + OrderId int `gorm:"uniqueIndex;not null"` // 1:1 bank_transfer_orders.id + UserId int `gorm:"index;not null"` + ReceiptEnc string `gorm:"not null"` // 回执图片,AES-256-GCM 加密 base64(复用 KYC 加密) + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` +} +``` + +- 独立表的理由与企业认证图片表相同:列表查询永不触碰 2MB 大字段,避免某处忘了 `Omit` 把回执批量拖出来;大字段省略 `type` 标签(MySQL→longtext)【决策点 D2】 +- 回执含银行账号信息,按敏感数据对待:**加密存储**(复用 `EncryptIDNumber`),管理员查看回执走独立接口并写 `LogTypeManage` 审计日志(轻量版,不做 KYC 那种状态感知收缩——回执敏感度低于身份证件) +- 客户端压缩复用 KYC 的 canvas 压缩(最长边 2400px / JPEG 0.88 / ≤1.5MB) + +#### `topups` 流水对接 + +审批通过时在**同一事务**内写一条 `TopUp` 记录,让对公转账出现在统一的充值历史里: + +```go +// model/topup.go 新增常量 +PaymentMethodBankTransfer = "bank_transfer" +PaymentProviderBankTransfer = "bank_transfer_manual" +``` + +`TopUp{ UserId, Amount: 折算美元额度, Money: 到账金额(元), TradeNo: order.TradeNo, Status: "success", PaymentMethod/Provider 如上 }`。 + +### 2.4 状态机与接口 + +| 接口 | 权限 | 说明 | +|------|------|------| +| `GET /api/user/bank_transfer/config` | UserAuth + 企业已认证 | 返回收款四要素 + min_amount_fen + tips(未启用/未认证时仅返回 enabled=false,不下发收款信息) | +| `GET /api/user/bank_transfer/self` | UserAuth | 自己的转账订单分页列表(不含回执大字段) | +| `POST /api/user/bank_transfer` | UserAuth + 企业已认证 + CriticalRateLimit | 提交订单(金额 + 备注 + 回执图必传);**限制:同一用户最多 1 笔 pending**,防刷单 | +| `DELETE /api/user/bank_transfer/:id` | UserAuth | 撤销自己的 pending 订单(软删,回执硬删) | +| `GET /api/user/bank_transfer/admin` | AdminAuth | 审核列表(状态筛选 + 按用户名/单号搜索,LEFT JOIN users 取 username,同 `GetEnterpriseList` 模式) | +| `GET /api/user/bank_transfer/admin/:id/receipt` | AdminAuth | 解密返回回执图(data URI),写审计日志 | +| `PUT /api/user/bank_transfer/admin/:id/approve` | AdminAuth | 请求体可带 `credited_fen` 修正到账金额(缺省=申报金额);事务入账 | +| `PUT /api/user/bank_transfer/admin/:id/reject` | AdminAuth | 必填原因 | + +> 实现说明(2026-06-10 落地时的微调):管理员路由按企业认证的既有约定挂在 `/api/user/bank_transfer/admin/*`(而非早稿的 `/api/topup/...`);配置以分层 option `bank_transfer_setting.*` 注册(`setting/operation_setting/bank_transfer_setting.go`),零改动 `model/option.go`。 + +**审批事务**(并发安全:条件更新抢占,2026-06-10 评审第 1 轮修订): + +``` +BEGIN + SELECT ... FROM bank_transfer_orders WHERE id=? -- 仅取不可变字段与存在性 + quota = credited_fen→元 / 系统汇率参数(7.3) * QuotaPerUnit -- 固定换算,见 D3 + UPDATE bank_transfer_orders SET status=2, credited_fen, quota_granted, reviewed_by, reviewed_at + WHERE id=? AND status=1 -- 条件更新抢占 + if RowsAffected == 0 → 返回"已处理"(并发审批/拒绝/撤销中已有人先赢) + INSERT INTO topups (...) -- status=success + IncreaseUserQuota(userId, quota) +COMMIT +RecordLog(LogTypeTopup, "对公转账充值 ¥xx,到账额度 $yy(审核人:zz)") +InvalidateUserCache(userId) +``` + +> **为什么不用 `FOR UPDATE` 行锁**(评审发现):上游惯用的 `tx.Set("gorm:query_option", "FOR UPDATE")` 是 GORM v1 机制,项目所用 GORM v2 会**静默忽略**它(等于无锁),且裸 `FOR UPDATE` 语法不兼容 SQLite。条件更新(`WHERE id=? AND status=pending` + 检查 RowsAffected)由数据库保证同行 UPDATE 串行,天然原子、幂等、三库兼容。拒绝、用户撤销两条路径同样用条件操作抢占,杜绝"审批已入账、撤销又删单"的交叉不一致。 +> +> **提交竞态(已评审接受,不修)**:创建订单的"同一用户最多 1 笔 pending"用 COUNT-then-CREATE 实现,极端并发下可能产生两笔 pending。接受理由:该约束只是防刷单 UX,**无资金风险**(每笔订单审批入账各自独立、各对应自己的回执);前端在有 pending 时隐藏表单 + 提交挂 CriticalRateLimit 已基本封死;而"正确"修法(部分唯一索引 `WHERE status=1`)MySQL 不支持,违反三库兼容。 +> +> **金额上限与 quota 列扩宽(评审第 2/3 轮修订,2026-06-10)**: +> - 后端业务上限 `BankTransferMaxAmountFen = 1e12`(¥100 亿),提交与审批两处校验,与前端 10 位整数限制对齐,防直连 API 提交天文数字导致 `decimal.IntPart()` 溢出。 +> - **`users.quota` / `users.used_quota` 由 `type:int` 升级为 `type:bigint`**:32 位列上限 2.147e9 quota ≈ $4294 ≈ **¥3.1 万余额**,对公转账大额入账(¥10 万 ≈ 6.85e9 quota)必然溢出(PG 报 integer out of range、MySQL 截断),且余额是累计的、压低单笔上限防不住。Go 侧 `int` 在 64 位平台即 64 位,仅改两个 gorm 标签。SQLite 已实测(glebarez/sqlite v1.9.0 + GORM v1.25.2):AutoMigrate 自动完成变更、存量数据完整、超 32 位值正常读写;PostgreSQL 升级部署时 AutoMigrate 执行 `ALTER COLUMN TYPE bigint`(users 表会重写,正常秒级)。`tokens.remain_quota` 无显式 type 标签、GORM 默认已映射 bigint,无需处理;`aff_quota` 等邀请额度金额小,本期不动。 + +> **换算口径(D3 已决策)**:用户只转人民币,模型也按人民币收费,quota 只是内部以美元计的额度单位。换算用**现有系统配置中的汇率参数(7.3)**,它是固定常数、不允许修改,审批界面不提供任何汇率/单价修改入口。管理员审批时唯一可修正的是**实际到账人民币金额**(手续费、实转与申报不符等场景),系统按固定参数自动折算 quota,`credited_fen` 与 `quota_granted` 同时落库留痕,事后可复算验证。 + +### 2.5 用户侧前端(web/classic 钱包管理页) + +`web/classic/src/pages/TopUp/` 内新增「对公转账」卡片(仅 `userState.user.enterprise_status === 2` 且 config.enabled 时渲染): + +- **收款信息区**:四要素展示 + 一键复制按钮(逐项复制) +- **提交区**:转账金额输入(≥ min_amount 校验)、备注(选填)、回执图片上传(必传,复用 KYC 压缩函数) +- **订单状态区**:最近订单列表(待审核可撤销;已拒绝显示原因 + 重新提交入口;已通过显示到账额度) +- 未通过企业认证的用户**看不到**该卡片(不展示"去认证"引导也可以,避免页面噪音——企业认证卡片在个人中心已有曝光) + +### 2.6 管理员侧前端 + +新增 `web/classic/src/pages/BankTransfer/index.jsx`(或并入现有充值管理页签,见【决策点 D4】): + +- 列:用户名、申报金额、备注、提交时间、状态、审核人、操作 +- 操作:查看回执(弹窗,关闭清空 state)、通过(弹窗内可修正到账金额,显示折算后的额度预览)、拒绝(填原因) +- 侧边栏 `DEFAULT_ADMIN_CONFIG.admin` 增加 `bankTransfer: true`,路由 `/console/bank-transfer` 入 `cardProPages` 白名单 + +--- + +## 三、功能 B:增值税发票 + +### 3.1 业务规则 + +- **可开票额度** = 该用户所有「已审批通过的对公转账」`credited_fen` 之和 − 已申请发票(pending + issued)金额之和【决策点 D5:是否扩大到在线充值】 +- 用户提交开票申请:金额(≤ 可开票额度)、发票类型(增值税普通发票 / 专用发票)、抬头、税号、收件邮箱、备注 + - 抬头默认预填企业认证的 `company_name`(明文字段,前端直接可得);税号由用户自己填写(USCC 在库里是密文,不为预填做解密接口) +- 管理员审核:开具后**上传发票文件**(PDF/图片),用户在钱包页下载;或拒绝并填原因【决策点 D6】 +- 拒绝后金额自动释放回可开票额度(计算口径只统计 pending + issued) + +### 3.2 数据库设计 + +#### 新增表 `invoice_requests` + +```go +// model/invoice.go +const ( + InvoiceStatusPending = 1 + InvoiceStatusIssued = 2 + InvoiceStatusRejected = 3 + + InvoiceTypeNormal = 1 // 增值税普通发票 + InvoiceTypeSpecial = 2 // 增值税专用发票 +) + +type InvoiceRequest struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + UserId int `json:"user_id" gorm:"index;not null"` + AmountFen int64 `json:"amount_fen" gorm:"not null"` + InvoiceType int `json:"invoice_type" gorm:"type:int;not null;default:1"` + Title string `json:"title" gorm:"type:varchar(128);not null"` // 发票抬头 + TaxNo string `json:"tax_no" gorm:"type:varchar(32);not null"` // 税号(明文:发票要素本就交付给用户) + Email string `json:"email" gorm:"type:varchar(128);not null"` // 接收邮箱 + Remark string `json:"remark,omitempty" gorm:"type:varchar(255)"` + Status int `json:"status" gorm:"type:int;not null;default:1"` + RejectReason string `json:"reject_reason,omitempty" gorm:"type:varchar(255)"` + ReviewedBy int `json:"reviewed_by,omitempty" gorm:"type:int"` + SubmittedAt *time.Time `json:"submitted_at"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} +``` + +#### 新增表 `invoice_files`(发票文件,1:1,管理员上传) + +```go +type InvoiceFile struct { + Id int `gorm:"primaryKey;autoIncrement"` + InvoiceId int `gorm:"uniqueIndex;not null"` + UserId int `gorm:"index;not null"` + FileName string `gorm:"type:varchar(128)"` // 原始文件名(含扩展名,前端据此区分 pdf/图片) + FileData string `gorm:"not null"` // base64,省略 type 标签(longtext);不加密——发票本身就是要交付给用户的文件 + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` +} +``` + +- 上传限制:PDF / JPG / PNG,原文件 ≤ 5MB(base64 后约 6.7MB,longtext 无压力) +- 发票文件**不加密**:它不是平台要保密的核验材料,而是交付物,用户本人可随时下载;省一次加解密复杂度 + +### 3.3 接口 + +| 接口 | 权限 | 说明 | +|------|------|------| +| `GET /api/user/invoice/quota` | UserAuth + 企业已认证 | 返回可开票额度(分)+ 抬头预填(企业认证公司名) | +| `GET /api/user/invoice/self` | UserAuth | 自己的发票申请列表 | +| `POST /api/user/invoice` | UserAuth + 企业已认证 + CriticalRateLimit | 提交申请;事务内复核可开票额度,超额拒绝;同一用户最多 1 笔 pending | +| `DELETE /api/user/invoice/:id` | UserAuth | 撤销 pending 申请(条件软删抢占) | +| `GET /api/user/invoice/:id/file` | UserAuth(仅本人)| 下载已开具的发票文件 | +| `GET /api/user/invoice/admin` | AdminAuth | 审核列表 | +| `PUT /api/user/invoice/admin/:id/issue` | AdminAuth | 上传发票文件(base64 JSON)+ 标记已开具(条件更新抢占) | +| `PUT /api/user/invoice/admin/:id/reject` | AdminAuth | 必填原因(条件更新抢占) | +| `GET /api/user/invoice/admin/:id/file` | AdminAuth | 管理员查看已开具的发票文件 | + +> **并发口径(2026-06-10 落地修订)**:提交侧"额度校验 + INSERT"的竞态窗口与转账提交同类(已评审接受,不依赖 FOR UPDATE——GORM v2 下无效);资金安全由**开具时的权威复核**兜底:`IssueInvoice` 事务内校验 `Σ(已开具) + 本笔 ≤ Σ(已通过转账)`,保证开出的发票永远不超过实际到账。issue/reject/cancel 三路径均用条件更新/软删抢占(`WHERE status=pending` + RowsAffected 检查)。 + +### 3.4 前端 + +- **用户侧**:钱包管理页「对公转账」卡片旁新增「发票」卡片(同样仅企业已认证可见):可开票额度展示、申请表单(抬头预填)、申请记录(已开具 → 下载按钮) +- **管理员侧**:与对公转账审核**同页不同页签**(Tabs:转账审核 / 发票审核),共用一个侧边栏入口,减少菜单膨胀【决策点 D4】。里程碑 1 先交付单页(仅转账审核),Tabs 结构随本里程碑(发票)落地时引入 + +--- + +## 四、功能 C:子账户 + +### 4.1 核心设计:账户类型怎么表达?(你提出的关键问题) + +三个候选方案的对比: + +| 维度 | 方案一:新增 Role 值(如 RoleSubUser=5) | 方案二:权限位 bitmask(permissions int64) | 方案三:`parent_user_id` 关系派生(**推荐**) | +|------|------|------|------| +| 语义 | ❌ Role 在本项目是**线性特权等级**,`authHelper(c, minRole)` 全靠 `role >= minRole` 比较。子账户不是"比普通用户低一级的信任等级",而是"隶属于某企业的受限视图",塞进线性序里语义错位(比如 `role=5` 的子账户能通过所有 `minRole<=1` 的检查吗?要么破坏序关系,要么全量重审每个判断点) | ⚠️ 表达力最强,但项目没有任何 bitmask 基建,为一个账户变体引入一整套权限系统,且"子账户属于谁"仍需另一个字段 | ✅ 子账户的本质就是"被某个企业账户拥有"。`parent_user_id > 0` ⇔ 是子账户,一个字段同时回答"是不是"和"属于谁",单一事实来源 | +| 改动面 | 大:全库 role 比较点逐个排查 | 大:新权限框架 + 旧逻辑适配 | 小:users 加一列 + 一个新中间件 + 4 个查询接口加过滤 | +| 上游合并 | 高风险(role 常量是上游核心) | 高风险 | 低风险(纯增量字段/表/路由) | +| 扩展性 | 差(再来一种账户类型怎么办) | 好 | 够用(未来如需更多账户变体,再演进为 type 字段也只是把 `parent_user_id>0` 的判断换掉,数据不动) | +| 未来"团队多角色" | 不支持 | 支持但超前 | 本期明确不做多角色(需求就是"只读子账户"一种),不为假想需求付费 | + +**结论**:不动 Role 体系、不引入权限位。`users` 表新增一列: + +```go +// model/user.go — User struct +ParentUserId int `json:"parent_user_id" gorm:"type:int;default:0;index;column:parent_user_id"` // >0 表示子账户,值为企业主账户 user_id +``` + +子账户的 `Role` 恒为 `RoleCommonUser`(沿用现有所有"普通用户"路径),**收紧**通过黑名单中间件实现(见 4.4),**数据范围**通过查询层过滤实现(见 4.5)。 + +同步(与 `EnterpriseStatus` 完全同模式): +- `ToBaseUser()` / `UserBase` / `WriteContext()` / `GetUserCache` Redis-miss 分支补 `ParentUserId` +- `constant/context_key.go` 新增 `ContextKeyUserParentId` +- 中间件侧新增 `readUserParentId(c)`(与 `readUserEnterpriseStatus` 同构) + +### 4.2 计费:为什么是零改动(设计根基) + +``` +子账户从不出现在计费链路里: + + 调用方拿着 key 请求 relay + → TokenAuth 用 key 查 tokens 表 → token.UserId = 企业账户 id + → PreConsume/PostConsume 全部以 token.UserId 扣减 + → 扣的天然就是企业账户的 quota,日志 user_id 也是企业账户 + + 「绑定」只是一条查看授权记录,不改变 key 的所有权(tokens.user_id 不动)。 + 子账户登录平台只是为了【看】,永远不为了【用】。 +``` + +这意味着:限流、分组、模型限制、信用额度、重试……所有现有计费行为对绑定后的 key 完全不变。**这是整个子账户功能最重要的不变量,实现与评审时都要守住。** + +### 4.3 子账户生命周期 + +#### 创建(企业账户操作) + +- 前置:操作者 `enterprise_status==2` 且自身 `parent_user_id==0`(子账户不能再创建子账户,天然防套娃) +- `POST /api/user/sub_account`:`{username, password, display_name}` + - username 走现有全局唯一校验与格式规则;密码走现有强度规则 + - 创建出的 user:`role=RoleCommonUser, status=enabled, quota=0, parent_user_id=企业id`,**不发放任何注册赠送额度、不参与邀请体系**(`inviter_id` 不写) +- 数量上限:`SubAccountMaxCount`(OptionMap,默认 **10**)【决策点 D7】 + +#### 企业账户的管理操作 + +| 接口 | 说明 | +|------|------| +| `GET /api/user/sub_account` | 自己的子账户列表(含每个子账户的绑定 key 数) | +| `POST /api/user/sub_account` | 创建 | +| `PUT /api/user/sub_account/:id/password` | 重置子账户密码 | +| `PUT /api/user/sub_account/:id/status` | 启用/禁用(复用 users.status,禁用后无法登录——现有 UserAuth 已检查 status) | +| `DELETE /api/user/sub_account/:id` | 删除子账户(软删 user)。**前置校验:该子账户名下存在任何绑定记录则拒绝删除**,提示"请先解除全部 key 绑定"(见下方"绑定保护") | +| `GET /api/user/sub_account/bindings?sub_id=` | 某子账户的绑定 key 列表 | +| `POST /api/user/sub_account/bind` | `{sub_user_id, token_id}` 绑定 | +| `POST /api/user/sub_account/unbind` | 解绑 | + +所有接口在 handler 内强校验归属:`sub.parent_user_id == 操作者id`、`token.user_id == 操作者id`,防越权(IDOR)。 + +#### 绑定保护:已绑定即禁删(D7 附带决策,2026-06-10) + +**绑定记录存在期间,绑定双方(key 与子账户)都不可删除,必须先解绑。** 目的:防止"删了 key 子账户日志悬空"、"删了子账户 key 绑定悬空"这类先后顺序导致的脏数据,也强迫操作者明确意识到自己在拆除一条授权关系。 + +| 操作 | 校验 | 行为 | +|------|------|------| +| 删除子账户(`DELETE /api/user/sub_account/:id`) | 绑定表中存在 `sub_user_id = :id` 的记录 | 拒绝,提示"该子账户绑定了 N 个令牌,请先解除绑定" | +| 删除令牌(`DELETE /api/token/:id`) | 绑定表中存在 `token_id = :id` 的记录 | 拒绝,提示"该令牌已绑定子账户 xx,请先解除绑定" | +| **批量删除令牌** | 同上,逐个校验 | 已绑定的拒绝并列出,未绑定的正常删除(或整批拒绝,实现时取交互更清晰者) | +| 禁用令牌 / 禁用子账户 | 不校验 | **允许**——禁用是可逆的临时管控,不破坏绑定关系 | + +> 实现位置:子账户删除在 `controller/sub_account.go` 自有 handler 内校验;令牌删除需在现有 `controller/token.go` 的删除路径(单删 + 批量)插入一次绑定表查询,这是子账户功能对上游 token 写路径唯一的侵入点,保持为"一次 SELECT + 提前 return"的最小改动。 + +#### 异常路径 + +- **企业认证被 reset**:已有子账户与绑定**保持可用**(只是看数据,无资金风险),但创建新子账户、新绑定被拒绝(创建/绑定接口实时校验 enterprise_status)【决策点 D8】 +- **企业账户被管理员删除**:账号删除路径跟随软删其全部子账户、硬删绑定表记录(参照现有 KYC/企业认证的账号删除跟随处理)。管理员删号是运维级操作,**不受**上述绑定保护约束(逃生通道) + +### 4.4 权限收紧:黑名单中间件 + +新增 `middleware.SubAccountForbidden()`,挂在子账户**不允许**碰的路由上(读 context 的 `ParentUserId`,>0 即 403 "子账户无权进行此操作"): + +| 路由组 | 理由 | +|--------|------| +| `/api/user/topup*`、`/api/user/pay`、`/api/user/amount` | 不能充值 | +| 兑换码使用(`/api/user/topup` 的 redemption 路径) | 不能充值 | +| `/api/token` 的 POST / PUT / DELETE(含批量) | 不能创建/修改/删除令牌 | +| `/api/user/aff*` | 不参与邀请体系 | +| `/api/user/kyc`、`/api/user/enterprise` 的写操作 | 子账户不是独立法律主体,不做认证 | +| `/api/user/sub_account*` | 不能套娃 | +| 签到(checkin)、订阅购买(subscription 写操作) | 一切能改变余额/产生消费承诺的入口全部封死 | +| `/api/user/bank_transfer`、`/api/user/invoice` 写操作 | 对公转账/发票是企业主账户的事 | + +**允许保留**:登录、登出、查看自己 profile。Playground 不专门封禁——子账户 quota=0,操练场消费天然失败;前端同时隐藏入口【决策点 D9】。 + +> ⚠️ **本段早稿曾写"允许保留:改自己密码、2FA/passkey",已被 M3-4/M3-9 推翻**(见 §9.3):子账户凭据统一由企业主账户管理,`PUT /self`(改用户名/密码/显示名)、passkey 注册、2FA setup/enable/backup_codes 均挂 `SubAccountForbidden` 后端封死。理由:子账户自设新登录因子会让企业"改密码吊销访问"的预期失效。凡冲突以 §9 为准。 + +> 实现注意:`SubAccountForbidden` 与 `UserAuth` 之间靠 context key 传递,UserAuth 路径已实时回源 DB(`middleware/auth.go` 会用最新 user 覆盖 session 快照),所以"主账户把某用户改成子账户"这种边缘操作也能即时生效。本期不提供"普通账户转子账户"功能,parent_user_id 只在创建时写入、不可变更。 + +### 4.5 数据范围:子账户能看什么、怎么过滤 + +#### key 绑定表 + +```go +// model/sub_account.go +type SubAccountTokenBinding struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + ParentUserId int `json:"parent_user_id" gorm:"index;not null"` + SubUserId int `json:"sub_user_id" gorm:"index;not null"` + TokenId int `json:"token_id" gorm:"uniqueIndex;not null"` // 一个 key 最多绑给一个子账户 + CreatedAt time.Time `json:"created_at"` +} +``` + +- **一个 key → 至多一个子账户**(uniqueIndex 兜底);**一个子账户 → 多个 key**。这是最简单且符合"按人分 key"直觉的形态;如果未来要"多个子账户共看一个 key",去掉唯一索引即可,表结构不用动 +- 用**独立绑定表**而非在 `tokens` 上加 `sub_user_id` 列:tokens 是上游核心表,合并冲突面大;绑定关系的增删也不应触发 token 的 updated 语义 + +#### 四类数据的过滤实现 + +子账户请求 self 系接口时,handler 检测 `ParentUserId > 0` 即切换查询:**user_id 用父账户 id,且 token 维度限定在绑定集合内**。 + +| 数据 | 表/现状 | 子账户查询 | 改动量 | +|------|---------|-----------|--------| +| **使用日志** | `logs` 已有 `token_id`(带索引) | `WHERE user_id={父} AND token_id IN ({绑定集合}) ...` 其余条件不变;`log/self`、`log/self/search`、`log/self/stat`、`log/self/export` 四个接口同处理 | 小:model 层函数加可选 `tokenIds []int` 参数 | +| **任务日志** | `tasks` **无顶层 `token_id` 列**(token id 仅存于 `private_data` JSON 内的 `TaskPrivateData.TokenId`,退款用) | `task/self` 加 `token_id IN ?` 过滤。**实施修正(见 §9.5 第 3 轮)**:早稿误以为 tasks 已有 token_id 真列,实际没有;已给 `Task` 加真列 `token_id` + `BeforeSave` 钩子从 `PrivateData.TokenId` 镜像,AutoMigrate 自动建列、零写路径侵入。老任务 token_id=0 不回填(特性未上线,无历史绑定任务) | 小 | +| **绘图日志** | `midjourneys` **没有 token_id** | ✅ **已决策(2026-06-10):本期不对子账户开放**。`mj/self` 对子账户直接返回 403(挂 `SubAccountForbidden`),前端隐藏绘图日志菜单。不给 midjourneys 加列、不碰 MJ 写入路径。若未来要开放,再按"加 token_id 列、增量生效"方案扩展 | 零 | +| **数据看板** | `quota_data` 聚合表**没有 token 维度**(按 user+model+小时聚合) | ✅ **已决策(2026-06-10):不动 quota_data**。子账户的 `data/self` 改为从 `logs` 实时聚合:`SELECT model_name, created_at/3600*3600 AS hour, COUNT(*), SUM(quota), SUM(prompt_tokens+completion_tokens) FROM logs WHERE user_id={父} AND token_id IN (...) AND type=2 AND created_at BETWEEN ... GROUP BY model_name, hour`,返回结构对齐 QuotaData,前端零改动;企业账户/普通用户的看板路径一字不改。子账户看板查询限 30 天窗口 | 中 | + +> **为什么不给 quota_data 加 token 维度**:聚合 key 从 `user+model+hour` 变成 `user+token+model+hour` 会让该表行数随 key 数线性膨胀,且触碰上游热路径(`logQuotaDataCache`)。子账户是低频查看场景,从 logs 实时聚合(有 `idx_user_id_id` 索引 + 时间窗限制)完全够用,主账户看板路径一字不改。 +> +> **查询窗口**:子账户看板/日志沿用现有的时间窗限制(如日志 30 天窗口),从 logs 聚合的看板默认限 30 天,避免大范围扫表。 + +#### 子账户的令牌页(只读) + +- `GET /api/token`:子账户返回**绑定的 key 列表**(read-only 标记),**含 key 明文**——子账户拿到 key 才能在自己的应用里使用它,这正是绑定的目的【决策点 D11】 +- 前端令牌页对子账户隐藏创建/编辑/删除/重置按钮,仅保留查看 + 复制 + +### 4.6 前端(web/classic) + +#### 子账户登录后的视图 + +侧边栏在 `useSidebar.js` 渲染层根据 `userState.user.parent_user_id > 0` **强制覆盖**(不依赖用户自己的 Setting JSON): + +| 模块 | 子账户 | +|------|--------| +| 数据看板(detail)| ✅(已过滤) | +| 令牌(token)| ✅ 只读 | +| 使用日志(log)/ 任务(task)| ✅(已过滤) | +| 绘图日志(midjourney)| ❌ 隐藏(本期不开放,接口 403)【D10】 | +| **钱包管理(topup)** | ❌ 隐藏 | +| 操练场 / 聊天 | ❌ 隐藏【D9】 | +| 个人设置 | ✅ 但隐藏 KYC/企业认证卡片、邀请卡片,仅保留密码/2FA 等安全设置 | +| 管理员区 | ❌(role=1 本来就没有) | + +#### 企业账户的子账户管理页 + +新增 `web/classic/src/pages/SubAccount/index.jsx`,路由 `/console/sub-accounts`,**仅 `enterprise_status===2` 显示菜单入口**: + +- 子账户列表:用户名、显示名、状态、绑定 key 数、创建时间;操作:重置密码 / 禁用 / 删除 +- 创建弹窗:用户名 + 密码 + 显示名 +- 绑定管理弹窗:左侧该子账户已绑 key,右侧企业自己的未绑 key 列表,双向移动 +- 令牌管理页(企业视角)每行可顺带显示"已绑定:子账户名"徽标(绑定列表接口返回映射即可,低优先级) + +#### i18n + +全部新增中文 key 在 8 个 locale(zh/zh-CN/zh-TW/en/fr/ja/ru/vi)同步补齐,关键词:对公转账、收款单位、收款账号、开户行、转账回执、到账金额、可开票额度、增值税发票、发票抬头、税号、子账户、绑定令牌、重置密码等。 + +--- + +## 五、改动文件清单(汇总) + +### 新增文件 + +| 文件 | 说明 | +|------|------| +| `model/bank_transfer.go` | 转账订单 + 回执表 + CRUD + 审批事务 | +| `model/invoice.go` | 发票申请 + 文件表 + 可开票额度计算 | +| `model/sub_account.go` | 绑定表 + 子账户 CRUD + 归属校验 | +| `dto/bank_transfer.go` / `dto/invoice.go` / `dto/sub_account.go` | 请求/响应 DTO | +| `controller/bank_transfer.go` / `controller/invoice.go` / `controller/sub_account.go` | 用户侧 + 管理员侧 handler | +| `setting/payment_bank_transfer.go` | 对公转账收款配置 | +| `middleware/sub_account.go` | `SubAccountForbidden` + `readUserParentId` | +| `web/classic/src/pages/BankTransfer/index.jsx` | 管理员:转账/发票审核页(双页签) | +| `web/classic/src/pages/SubAccount/index.jsx` | 企业:子账户管理页 | +| `web/classic/src/components/topup/cards/BankTransferCard.jsx`、`InvoiceCard.jsx` | 钱包页两张卡片 | + +### 修改文件 + +| 文件 | 改动 | +|------|------| +| `model/user.go` | `ParentUserId` 字段;账号删除路径跟随处理子账户/绑定/转账/发票表 | +| `model/user_cache.go` / `constant/context_key.go` | ParentUserId 进缓存与 context | +| `model/main.go` | AutoMigrate 新表 ×5 | +| `model/topup.go` | 两个常量 | +| `model/log.go` / `model/task.go` / `model/usedata.go` | self 查询支持 tokenIds 过滤;看板 logs 聚合函数 | +| `controller/log.go` / `task.go` / `usedata.go` / `token.go` | 子账户分支(切父 id + 绑定集合过滤;token 列表只读分支;token 单删/批量删插入绑定保护校验,D7) | +| `router/api-router.go` | 新路由 + `SubAccountForbidden` 挂载(含 `mj/self`,D10) | +| `web/classic/src/pages/Setting/Operation/SettingsGeneral.jsx` | 汇率与额度展示类型置灰锁定(D3 附带) | +| `web/classic` | TopUp 页挂卡片、useSidebar 子账户覆盖 + 新菜单、令牌页只读模式、个人设置卡片隐藏、路由注册、8 locale i18n | + +--- + +## 六、实施顺序(三个功能可独立交付) + +**里程碑 1:对公转账**(最高优先级,直接产生收入) +1. setting 配置 → model(订单+回执+事务)→ controller → 路由 +2. 前端:支付设置表单 → 钱包卡片 → 审核页 + +**里程碑 2:发票**(依赖转账数据) +3. model(申请+文件+额度计算)→ controller → 路由 → 前端两端 + +**里程碑 3:子账户** +4. users.ParentUserId + 缓存/context 打通 → 绑定表 + 子账户 CRUD +5. `SubAccountForbidden` 全量挂载(先封死写入口) +6. 数据过滤(log → task → 看板 logs 聚合;绘图日志不开放,仅挂 403) +7. 前端:子账户管理页 → 侧边栏覆盖 → 令牌只读 → i18n + +--- + +## 七、测试验收要点 + +### 对公转账 +- 未认证用户:卡片不可见,直连 POST 返回 403 +- 提交(金额 < min_amount 拒绝;无回执拒绝;已有 pending 拒绝) +- 审批:通过后 quota 增加、topups 出现 success 流水、充值日志可见;两管理员并发审批仅一个成功(行锁幂等);修正到账金额生效且留痕 +- 回执:列表接口不含大字段;查看回执写审计日志;DB 中为密文 + +### 发票 +- 可开票额度 = 通过的转账 − (pending+issued);拒绝后额度释放 +- 超额申请被服务端拒绝;文件上传/下载闭环;非本人下载 403 + +### 子账户 +- **计费不变量**:绑定前后,key 的 relay 行为完全一致(限流/分组/扣费对象均为企业账户) +- 黑名单:子账户直连充值/令牌写/兑换/签到/aff 等接口全部 403 +- 数据隔离:子账户 A 看不到子账户 B 绑定 key 的日志;看不到企业未绑定 key 的日志;看板数字与按 token 手工汇总一致 +- 越权:企业 X 不能绑企业 Y 的 token / 不能管理 Y 的子账户(IDOR 全套) +- 生命周期:禁用子账户即无法登录;企业认证 reset 后存量子账户可用、新建被拒 +- 绑定保护:删除已绑定的子账户被拒(提示绑定数量);删除/批量删除已绑定的 key 被拒(提示绑定的子账户);解绑后删除成功;禁用 key/子账户不受绑定限制 + +--- + +## 八、决策点(已全部评审确定,2026-06-10) + +| # | 决策点 | 候选 | 推荐 | +|---|--------|------|------| +| **D1** | 新表金额单位 | a) int64 分;b) float64 元(与 topups.money 一致) | ✅ **已决策(2026-06-10):a**。附带实现规约见 §2.3(Fen 后缀命名 / 统一换算函数 / 前端字符串解析 / 4 个边界点) | +| **D2** | 回执图片存储 | a) 独立表 + 加密(同 KYC 图片模式);b) 订单表大字段 + 查询 Omit | ✅ **已决策(2026-06-10):a** | +| **D3** | 到账额度折算 | a) 系统自动折算,审批时仅可修正到账金额;b) 管理员审批时手填最终额度 | ✅ **已决策(2026-06-10):a**。换算参数=现有系统配置 `USDExchangeRate`(7.3,元→美元额度),不允许修改、界面无修改入口;管理员只确认/修正实际到账人民币金额,quota 由系统折算并落库留痕。**附带**:系统设置页将 `USDExchangeRate` 与 `quota_display_type` 置灰锁定防误操作(见 §2.2) | +| **D4** | 管理员审核页形态 | a) 转账+发票同页双页签,一个菜单入口;b) 两个独立页面 | ✅ **已决策(2026-06-10):a** | +| **D5** | 可开票范围 | a) 仅对公转账到账金额;b) 全部成功充值(含在线支付) | ✅ **已决策(2026-06-10):a**。在线充值开票牵涉支付平台流水核对,后续再扩 | +| **D6** | 发票交付方式 | a) 管理员上传文件、用户在线下载;b) 线下邮件发送、系统只记状态 | ✅ **已决策(2026-06-10):a** | +| **D7** | 子账户数量上限默认值 | 10 / 20 / 50(OptionMap 可调) | ✅ **已决策(2026-06-10):10**,后台可调。**附带:绑定保护规则**——已绑定的 key 与子账户均禁止删除,须先解绑(见 §4.3"绑定保护"),禁用不受限 | +| **D8** | 企业认证 reset 后子账户 | a) 保留可用,冻结新建/新绑定;b) 全部自动禁用 | ✅ **已决策(2026-06-10):a**。纯只读无资金风险,避免误伤正常使用 | +| **D9** | 子账户与操练场/聊天 | a) 前端隐藏 + quota=0 天然失败,不专门封接口;b) 中间件显式封禁 | ✅ **已决策(2026-06-10):a**。操练场消费走登录用户自身 quota(子账户恒为 0),绕过前端直调也花不掉企业的钱 | +| **D10** | 绘图日志按 key 过滤 | a) midjourneys 加 token_id 列(增量生效,老数据子账户不可见);b) 本期子账户不提供绘图日志 | ✅ **已决策(2026-06-10):b**。子账户菜单隐藏绘图日志、`mj/self` 返回 403;不动 midjourneys 表和 MJ 写入路径。未来需要时按 a 方案扩展 | +| **D11** | 子账户能否看到绑定 key 明文 | a) 能(子账户要拿 key 去用);b) 不能(key 由企业线下分发) | ✅ **已决策(2026-06-10):a** | +| **D12** | 子账户能否看到 key 的剩余额度(token.RemainQuota)| a) 能(只读展示);b) 隐藏额度字段 | ✅ **已决策(2026-06-10):a**。企业账户的总余额(users.quota)不向子账户下发 | + +--- + +## 附:三功能依赖关系 + +``` +企业认证(已上线) + ├── 对公转账充值(A)──► 增值税发票(B,开票额度依赖 A 的到账记录) + └── 子账户(C,独立,仅依赖企业认证状态) +``` + +A/C 可并行开发;B 依赖 A 的数据模型先行落地。 + +--- + +# 九、实施记录与交接(2026-06-11 更新) + +> 本章是给**接手 agent** 的单一事实来源。读完本章即可知道:已经做了什么、为什么这么做、 +> 当前代码处于什么状态、下一步必须做什么。前面 §一~§八 是原始设计,本章记录**实施过程中 +> 的实际落地与对设计的偏差/补充决策**,凡与前文冲突处**以本章为准**。 + +## 9.1 总体进度快照 + +| 里程碑 | 状态 | 备注 | +|--------|------|------| +| **M1 对公转账** | ✅ 已完成、已提交、已过 4 轮 Codex 评审收敛 | commit `2ddbfd6ca` | +| **M2 增值税发票** | ✅ 已完成、已提交(含并发超开修复) | commit `20105463f` + `4eeb74c4d` | +| **M3 子账户** | 🟡 **代码全部完成、两端构建通过、已过 2 轮 Codex 评审收敛、但尚未提交** | 全部在工作区未提交(见 9.5) | + +**当前分支**:`feat/bank-transfer`(M1+M2 已提交在此分支;M3 改动未提交叠在其上)。 +该分支为本地分支,尚未推送、尚未建 PR——按用户要求"封存",等用户决定何时推。 + +**构建验证基线**(每轮改完都跑过,接手后改动也必须保持绿): +- 后端:`go build ./...` + `go vet ./controller/... ./model/... ./middleware/... ./router/...` +- 前端:`cd web/classic && bun run build` +- 冒烟:`go build -o /tmp/x . && PORT=33xxx /tmp/x`,确认无 gin 路由冲突 panic、SQLite AutoMigrate 无错(`sub_account_token_bindings` 表 + `users.parent_user_id` 列建出) + +## 9.2 M3 子账户——实际落地清单(与 §四 设计一致,下列为最终实现) + +**新增文件** +- `model/sub_account.go`:`SubAccountTokenBinding` 表;子账户 CRUD(`CreateSubAccount` 走专用路径:quota=0、不发赠送额度、不写 inviter、事务内复检数量上限防并发越限);绑定/解绑(事务内强校验归属 + token 唯一性);绑定保护查询;`cascadeDeleteSubAccountsForParent`(企业删号级联)。文件头记录了"已知并接受的竞态"(见 9.4)。 +- `setting/operation_setting/sub_account_setting.go`:`SubAccountMaxCount` 默认 10(D7),走 `config.GlobalConfig.Register`,`GetSubAccountMaxCount()` 对非法值回退 10。 +- `middleware/sub_account.go`:`SubAccountForbidden()`(role≥Admin 放行;`parent_user_id>0` → 403)+ `readUserParentId()`(与 `readUserEnterpriseStatus` 同构:先读 context key,UserAuth 链路回源 `GetUserCache`)。 +- `dto/sub_account.go`:请求/响应 DTO。 +- `controller/sub_account.go`:8 个子账户管理 handler(创建/列表/重置密码/启停/删除/绑定列表/绑定/解绑),全部 `requireEnterpriseApproved` 前置 + handler 内 IDOR 归属校验。**另含两个共享 helper**:`resolveSelfDataScope`(日志/任务/看板用)与下文令牌读接口共用思路。JSON 解码统一用 `common.DecodeJson`(CLAUDE.md Rule 1)。 +- `web/classic/src/pages/SubAccount/index.jsx`:企业主的子账户管理页(列表 + 创建弹窗 + 重置密码弹窗 + 绑定管理弹窗)。绑定弹窗的令牌选择器用**远程搜索**(`GET /api/token/search`,300ms 防抖)突破 100 条分页上限。 + +**关键修改文件** +- `model/user.go`:`User` 加 `ParentUserId`(真列,index)+ `ParentUsername`(瞬态 `gorm:"-:all"`,仅管理员列表填充展示归属);`ToBaseUser` 同步;`User.Delete()`(软删)与 `HardDeleteUserById()`(硬删)**两条删除路径都加了子账户级联**(软删子账户 + 硬删绑定);新增 `FillParentUsernames`(批量填充避免 N+1)。 +- `model/user_cache.go` / `constant/context_key.go`:`UserBase` 加 `ParentUserId`,`WriteContext`/`GetUserCache` 同步,新增 `ContextKeyUserParentId`。 +- `model/log.go`/`task.go`/`usedata.go`:self 查询加可选 `tokenIds []int` 过滤(logs `GetUserLogs`/`ExportUserLogs`/`SumUsedQuota`、task `SyncTaskQueryParams.TokenIds`);看板新增 `GetQuotaDataFromLogsByTokenIds`(从 logs 实时聚合,**不动 quota_data**,小时分桶用 `created_at - created_at%3600` 跨库通用)。 +- `model/token.go`:`SearchUserTokens` 加 `tokenIds []int` 过滤;新增 `GetTokensByIdsAndUser`。 +- `controller/log.go`/`task.go`/`usedata.go`:用 `resolveSelfDataScope(c)` 切换"子账户→父 id + 绑定集合"。**关键安全点**:子账户空绑定集合时短路返回空,**绝不把空集合当成不过滤**(否则泄漏企业主全量数据)。 +- `controller/token.go`:4 个令牌读接口(`GetAllTokens`/`SearchTokens`/`GetToken`/`GetTokenKey`)**统一收口到 `resolveTokenReadScope(c)`**——子账户切父 id、限定绑定集合、列表脱敏分页切片、取 key 校验绑定归属。`DeleteToken`/`DeleteTokenBatch` 加绑定保护(已绑定令牌拒删)。 +- `controller/user.go`:`GetSelf` + `setupLogin` 下发 `parent_user_id`/`enterprise_status`/`kyc_status`(**登录态必须带这些字段,否则前端子账户视图覆盖失效**);`GetAllUsers`/`SearchUsers` 调 `FillParentUsernames`;`ManageUser` 的 `promote`/`demote` 对 `parent_user_id>0` 拒绝。 +- `router/api-router.go`:`SubAccountForbidden` 挂载到充值/支付/令牌写/兑换/签到/aff/KYC写/企业写/对公转账写/发票写/订阅写/`mj/self`/`DELETE /self`/`sub_account/*`;新增 8 条 `sub_account` 路由(绑定/解绑用 `/:id/bind`、`/:id/unbind` 避免 gin 静态/参数同级冲突)。 +- 前端多文件:`SiderBar.jsx`(子账户菜单强制覆盖)、`UserArea.jsx`(头像下拉隐藏钱包/个人设置)、`StatsCards.jsx`(看板隐藏充值按钮)、`PersonalSetting.jsx`(隐藏 KYC/企业卡片)、`TokensTable/TokensColumnDefs/TokensActions`(令牌页只读)、`UsersColumnDefs.jsx`(角色/分组/归属/操作按钮)、`render.jsx`(subAccounts 图标)、`App.jsx`(路由)、`PageLayout.jsx`(登录后拉 /self 刷新登录态)。 + +## 9.3 M3 实施期补充决策(§八 之外,实施/联调中新增,**接手必须遵守**) + +| 编号 | 决策 | 结论与理由 | +|------|------|-----------| +| **M3-1** | 登录态字段下发 | `setupLogin` 与 `GetSelf` 都必须返回 `parent_user_id`/`enterprise_status`/`kyc_status`。`PageLayout.loadUser` 加载本地快照后**异步拉一次 `/api/user/self`** 刷新——覆盖旧版本登录的存量用户、管理员中途改状态。前端所有子账户视图覆盖依赖这些字段即时生效。 | +| **M3-2** | 令牌读接口统一收口 | 4 个令牌读接口(list/search/detail/key)全部走 `resolveTokenReadScope`。绑定 key 的 `tokens.user_id` 留在企业主身上,子账户所有读接口必须切"父 id + 绑定集合",否则按子账户自身 id 查恒为空。**新增令牌读接口必须复用该 helper。** | +| **M3-3** | 钱包/个人设置/聊天/绘图 对子账户隐藏(前端三处入口都要堵) | 不止侧边栏:① `SiderBar` 个人中心组隐藏钱包+个人设置、聊天组整组隐藏、绘图日志隐藏;② 头像下拉 `UserArea` 隐藏钱包+个人设置;③ 数据看板 `StatsCards` 余额卡隐藏"充值"按钮。**新增任何指向充值页/个人设置的入口都要同步对子账户隐藏。** 注意:前端隐藏只是体验,后端 `SubAccountForbidden` 才是安全边界。 | +| **M3-4** | 个人设置对子账户隐藏(连带影响) | 用户选择隐藏整个"个人设置"页。连带后果:子账户**无法自助改密码/设 2FA/Passkey**——密码改由企业在子账户管理页重置(已实现)。因此管理员侧"重置 Passkey/2FA"对子账户**无的放矢**,已在管理员用户表对子账户行隐藏。 | +| **M3-5** | 管理员用户表对子账户的呈现 | ① 角色列:`parent_user_id>0` 显示**紫色"企业子用户"**,`enterprise_status===2`(非子)显示**青色"企业用户"**,管理员/超管不变;② 分组列:子账户显示灰色 `-`(其 group 纯惰性,计费走企业主,显示会误导);③ 用户名列:子账户下方加灰字 `隶属 {企业主用户名}(#id)`(方案 A,后端 `FillParentUsernames` 批量填充)。 | +| **M3-6** | 管理员对子账户的操作面收敛 | 子账户行**只保留 禁用/启用、编辑、注销**。隐藏 **提升/降级**(提升有害:会造出绕过限制的矛盾管理员;后端 `ManageUser` promote/demote 已对 `parent_user_id>0` 加守卫兜底)、**订阅管理/重置Passkey/重置2FA**(对子账户无意义)。`Edit()` 是白名单更新(username/display_name/group/remark/password),**不碰 parent_user_id 也不碰 quota**,编辑子账户安全。 | +| **M3-7** | 工单(我的工单)对子账户开放 | 用户确认:子账户与企业主的工单**各归各的**(按各自 user_id 隔离),符合预期。工单路由**不挂** `SubAccountForbidden`,菜单保留。不做企业聚合。 | +| **M3-8** | 跨库与边界 | 子账户令牌列表分页用内存切片(绑定数小),并对 `GetPageQuery` 不钳位的负数 `p` 自防越界 panic。看板 logs 聚合限 30 天窗口。 | +| **M3-9** | 子账户凭据自管后端封死(第 3 轮 Codex,落实 M3-4 意图,**推翻 §4.4 早稿**) | M3-4 定下"子账户凭据由企业主管理",但**后端一度只前端隐藏个人设置、未堵接口**——子账户直连 API 仍可 `PUT /self` 自改用户名/密码/显示名、自设 passkey/2FA。按 §9.7-4"后端才是安全边界",已给 `PUT /self`、`passkey/register/begin+finish`、`2fa/setup+enable+backup_codes` 全挂 `SubAccountForbidden`(`router/api-router.go`)。**保留** passkey verify/delete、2fa disable/status(减因子无害,且 delete/disable 是降权)。理由:子账户自设新登录因子会让企业"改密码吊销访问"失效(企业真正的吊销手段是禁用,禁用拦一切登录含 passkey,不受影响)。前端三处 `PUT /self` 调用方均在已隐藏的个人设置页内,堵后不会弹 403。 | + +## 9.4 已知并接受的竞态/限制(评审确认,**不修**,接手勿当 bug 重开) + +- **删子账户/删令牌 vs 并发绑定**:`DeleteSubAccount`(事务内查绑定)与 `DeleteToken/Batch`(绑定保护检查在删除事务外)与并发 `bind` 交错,极端情况可能残留一条孤儿绑定记录。后果仅为企业主绑定列表多一条可手动解绑的记录,**不泄漏、不涉资金**,且为管理 UI 单人低频操作,故不上跨表行锁(对比 `IssueInvoice` 上了用户行锁是因为涉及资金)。详见 `model/sub_account.go` 文件头注释。 +- **子账户的 group/quota/订阅** 为惰性字段(子账户不发起计费流量,绑定 key 计费走企业主):管理员给子账户调分组/加额度/发订阅均无实际效果,不报错、无害,**有意不在编辑弹窗对子账户禁用这些字段**(打磨性价比低)。 +- **任务 `token_id` 老数据不回填**:第 3 轮加的 `tasks.token_id` 真列对 AutoMigrate 之前的存量任务为 0,子账户看不到加列前的历史任务。接受理由:企业子账户特性尚未上线,不存在历史绑定任务;真回填需解析 `private_data` JSON(跨库 JSON 操作,撞 Rule 2),收益≈0。新任务由 `BeforeSave` 钩子正常回填。 +- **i18n**:站点已锁定简体中文 + 隐藏语言切换,`t()` 对无翻译键回退中文 key,故 M3 新增中文 UI 在 zh-CN 下显示正常。**未**对其余 6 语种(zh-TW/en/fr/ja/ru/vi)批量翻译前端新增 key(无可见收益)。后端 i18n 仅 `sub_account.forbidden` 补了 zh-CN/zh-TW/en 三语。**若未来要开放多语种,需补前端 locale。** + +## 9.5 评审记录(M3,已收敛) + +- **第 1 轮 Codex**:4 条 → 全修。①硬删用户未级联子账户/绑定(数据死锁隐患,已在 `HardDeleteUserById` 补级联);②绑定弹窗只能选前 100 个令牌(改远程搜索);③子账户令牌列表忽略分页(改内存切片 + 防越界);④JSON 未走 `common.DecodeJson`(已替换)。 +- **第 2 轮 Codex**:1 条 → 全修。令牌 search/detail 接口仍按子账户自身 id 查(已统一收口到 `resolveTokenReadScope`,4 个读接口一致)。 +- 此后又做了 M3-3~M3-8 的 UI 精化(用户逐项确认)。 +- **第 3 轮 Codex(覆盖 M3-3~M3-8 UI/守卫精化)**:报 3 项,处置如下: + - **P1 任务过滤撞 `tasks` 无 `token_id` 真列** → 修:`Task` 加真列 + `BeforeSave` 镜像 `PrivateData.TokenId`(`model/task.go`),AutoMigrate 自动建列,冒烟确认。详见 §4.5 已更正。 + - **P2 `batch/keys` 未走子账户 scope** → 修:`GetTokenKeysBatch` 改走 `resolveTokenReadScope` + `isBound` 过滤 + 空绑定短路(`controller/token.go`)。 + - **P2 `PUT /self` + passkey/2FA 子账户可直连自管凭据** → 修:见 M3-9(全挂 `SubAccountForbidden`)。 + - **(同轮再报)老任务 token_id 不回填** → **已知接受**(见 §9.4),特性未上线无历史绑定任务,不回填。 + - **绘图日志是否同法开放** → 用户决定**维持 D10 现状不动**(不碰 MJ 写路径)。 +- **第 4 轮 Codex(覆盖 §9.8 UI/字段/令牌名/凭据封堵全量)**:报 1 条 P1 → 全修。子账户页 `columns` 的 `useMemo` 写在 `if(!isEnterpriseOwner) return` 之后,登录态刷新(M3-1 的 `/api/user/self` 流程)使 `isEnterpriseOwner` false→true 时 hook 数量变化 → React「Rendered more hooks」崩页。修:`columns` 改普通 const(`baseColumns` 本就每次 render 重算,零性能影响),消除条件 return 后的唯一 hook。 +- **第 5 轮 Codex**:报 2 条,处置: + - **P1「子账户经 access token 绕过 `SubAccountForbidden`」→ 经核实为误报**。`readUserParentId`(middleware/sub_account.go)不依赖 UserAuth 写的 context key,miss 时用 `c.GetInt("id")` 回源 `GetUserCache(id).ParentUserId`;access-token 分支(auth.go 的 authHelper)已设正确的 `id`,故挂 `SubAccountForbidden` 的写路由仍能 403。不改。 + - **P2「BeforeSave 可能把 token_id 清零」→ 实际无清零路径**(所有 struct 存盘都加载完整 task、PrivateData.TokenId 完整;批量走 map 更新不碰 token_id;老任务被加载后反而回填),但加了 1 行护栏 `if t.PrivateData.TokenId != 0` 作未来兜底(`model/task.go`)。 +- 第 4/5 轮修复(hooks const 化 + token_id 护栏)后已收敛。 + +## 9.6 交接:接手 agent 的待办(按顺序) + +> 用户的工作流约定(务必遵守):**改完不要擅自 commit**;用 Codex review,逐条分析合理性, +> 与用户讨论后由用户决策是否修改,修完重新全量 review,直到用户认为收敛。 + +1. **【立即可做】对 M3-3~M3-8 的 UI/守卫精化跑一轮 `/codex:review`**(工作区 diff),把发现逐条分析后交用户决策。这是当前唯一悬空的验证项。 +2. **收敛后等用户指令再提交**。提交建议拆分:M3 主体一个 commit(`feat(enterprise): 企业子账户(里程碑3)`),其后各轮评审修复可合入或单独 commit,参照 M1/M2 的 commit 粒度。**不要自己推送/建 PR**,等用户决定。 +3. **人工验收**(按 §七"子账户"清单 + 9.3 各项):重点验 + - 计费不变量:子账户用绑定 key 调用,扣的是企业主 quota、日志 user_id=企业主、限流/分组不变; + - 数据隔离:子账户只看到自己绑定 key 的日志/任务/看板,看不到企业其他 key;空绑定时全部返回空(不是全量); + - 黑名单:子账户直连充值/令牌写/兑换/签到/aff/认证/对公转账/发票写/`mj/self`/`DELETE /self` 全 403; + - 令牌页:子账户只读(无增删改、无批量、无搜索泄漏),能看绑定 key 列表 + 点按取明文 key + 看余额; + - 生命周期与绑定保护:禁用子账户即无法登录(但绑定 key 照常工作);删已绑定的子账户/令牌被拒;删企业主级联清理子账户+绑定; + - 管理员侧:用户表角色色/分组`-`/隶属标签正确;子账户行只剩禁用·编辑·注销;promote/demote 子账户被后端拒。 +4. **可选的未来增强**(用户已知、本期不做,勿擅自开工): + - 反向归属视图(企业主看名下子账户:方案 C 筛选/方案 B 展开行,见对话); + - 子账户绘图日志(D10 的 a 方案:midjourneys 加 token_id 列); + - 在线充值开票(D5 b); + - 前端 6 语种 i18n 补全; + - 编辑弹窗对子账户禁用 group/quota/订阅字段(打磨)。 + +## 9.7 给接手 agent 的关键不变量(一句话清单,改任何代码前先确认不破坏) + +1. **计费零改动**:绑定只是查看授权,`tokens.user_id` 永远不变,子账户永不进计费链路。 +2. **空绑定集合 ≠ 不过滤**:任何子账户数据/令牌读接口,空绑定必须短路返回空。 +3. **令牌读接口必走 `resolveTokenReadScope`**;自身数据(日志/任务/看板)必走 `resolveSelfDataScope`。 +4. **后端是安全边界**:前端隐藏只是体验;新增任何写入口/敏感读,都要评估是否挂 `SubAccountForbidden` 或在 handler 校验 `parent_user_id`。 +5. **`parent_user_id` 只在创建时写、不可变更**;`Edit()` 不得纳入该字段;删除两条路径都要级联清理绑定。 +6. **登录态三字段**(parent_user_id/enterprise_status/kyc_status)必须随 `setupLogin`/`GetSelf` 下发。 + +## 9.8 M3 UI/字段精化轮(2026-06-11,第 3 轮 Codex 之后的用户逐项打磨) + +> 本节记录 M3 主体落地后,用户对**子账户管理页**与**绑定弹窗**逐项验收提出的 UI/字段需求及实现。 +> 全部为 `web/classic` 前端 + 少量后端字段补充,**计费链路与安全边界不变**(§9.7 全部仍成立)。 +> 这些改动**尚未经 Codex 复审**,接手如继续改动需纳入下一轮 review。 + +### 9.8.1 子账户管理页重构为「用户管理」同款范式(M3-10) + +- **动机**:原页面用裸 `Card + Table`,未充分利用页面、无分页、无密度切换。 +- **落地**(`web/classic/src/pages/SubAccount/index.jsx`):改用 `CardPro type='type1'` + `CardTable` + `CompactModeToggle` + `createCardProPagination`,与 `components/table/users/` 一致: + - `descriptionArea`:标题(子账户管理 + 企业专属 Tag)+ 右上角**紧凑列表切换**; + - `actionsArea`:**「创建子账户」按钮移到工具栏**(参照「添加用户」,`size='small'` 素色,满额 disabled)+ 右侧**「子账户数量:N / 上限」计数 Tag**(满额变橙色)。早稿把数量塞在说明句尾显示为「N/上限」语义不清,**已改为独立带标签的计数**; + - **分页**:客户端切片(子账户上限小、后端一次返回全部),`createCardProPagination`,pageSize 10/20/50/100; + - 操作列 `fixed:'right'` + `width:340`、**表头与按钮左对齐**(分割线紧贴「管理绑定」,不再右对齐留大留白)。 + +### 9.8.2 字段增删(M3-11) + +- **删「显示名」**:列表列与创建表单字段一并移除(用户名已足够标识,显示名对只读子账户无意义)。创建请求不再带 `display_name`。 +- **加「最后使用时间」列**:取该子账户**绑定令牌中 `max(accessed_time)`**;无绑定/从未使用显示 `-`。 + - 后端新增 `model.GetLastUsedTimesByParent(parentId) map[int]int64`:**不用 JOIN**(先取绑定关系,再 `token_id IN (...)` 单查 `accessed_time`,纯 GORM 三库通用),`GetSubAccounts` 填充 `SubAccountResponse.LastUsedTime`。 + +### 9.8.3 绑定弹窗对齐「令牌管理」(M3-12) + +- **额度按额度展示类型显示**:原写死 `$`,改用站点统一 `helpers/render.jsx` 的 `renderQuota()`(按 `quota_display_type` 渲染,CNY 下显示 `¥`+汇率)。 +- **额度拆三列**(措辞对齐令牌管理):**已用额度** / **剩余额度(带百分比)** / **总额度**;`unlimited_quota` 时剩余额度与总额度两列显示**「无限额度」圆角 Tag**(`color='white' shape='circle'`),已用额度仍显实际值。 +- **删「密钥」列**:绑定弹窗不再展示/复制明文 key(连带移除未用的 `copy` import)。注意:D11「子账户可见绑定 key 明文」仍由**子账户自己的令牌页**承载(`resolveTokenReadScope`),此处是**企业主的绑定管理视图**,去掉 key 展示不影响 D11。 +- **加 状态 / 分组 / 可用模型 / 过期时间 列**(对齐令牌管理渲染): + - 状态:令牌四态 Tag(1 已启用绿 / 2 已禁用红 / 3 已过期黄 / 4 已耗尽灰); + - 分组:`auto`→「智能熔断」Tag,否则分组名(空→默认); + - 可用模型:未启用模型限制→「无限制」Tag,启用→「N 个模型」蓝 Tag + Tooltip 列出完整模型; + - 过期时间:`-1`→「永不过期」,否则格式化时间。 + - 后端 `SubAccountBindingResponse` 补 `used_quota`/`unlimited_quota`/`group`/`expired_time`/`model_limits_enabled`/`model_limits`。 +- **禁用/过期整行变灰**:`onRow` 对 `status !== 1` 的行设背景 `var(--semi-color-disabled-border)`(与令牌管理 `useTokensData.handleRow` 同色值同逻辑,覆盖禁用/过期/耗尽)。 +- **高度封顶**:绑定数 ≤10 全展示;**>10 限高约 10 行 + 垂直滚动条**(`scroll.y=420`),避免 20 个 key 时弹窗被拉很长;横向 `scroll.x='max-content'` 兜 9 列。弹窗宽度 960。 + +### 9.8.4 令牌名称同账户唯一(M3-13,**影响全站令牌写路径,非仅子账户**) + +- **动机**:绑定弹窗按**名称**远程搜索本企业令牌;同账户重名令牌会让「按名搜索 + 绑定」指向歧义、绑错对象,子账户也难按名识别。 +- **落地**:`model.IsTokenNameDuplicated(userId, name, excludeId)`(空名不去重、排除自身、软删天然过滤、`name` 非保留字三库通用)。 + - `AddToken`:创建必查重名 → 重复报「令牌名称已存在,请使用不同的名称」; + - `UpdateToken`:**仅当名称变化时**查重(历史重名令牌改额度等无关字段不被误拦,改名撞名才拦)。 +- **范围说明**:这是对**全站令牌管理写路径**的校验(不只企业账户)。存量重名令牌不受影响,但日后给它们改名撞名会被拦——即「防重名好管理」的目的。若仅想限企业账户范围,需另加条件收窄(当前未收窄)。 + +### 9.8.5 后台可调子账户上限(M3-14) + +- 在**系统设置 → 运营设置 → 通用设置**「用户最大令牌数量」右侧新增**「企业账户最大子账户数量」**输入框(`web/classic/src/pages/Setting/Operation/SettingsGeneral.jsx`),字段 `sub_account_setting.max_count`,默认 10,`min=1`。 +- 复用 `PUT /api/option` + `handleConfigUpdate` 通用分层 option 机制(**零后端改动**):存盘 → 写入 `subAccountSetting.MaxCount` → `GetSubAccountMaxCount()` → `GET /api/user/sub_account` 的 `max_count` → 子账户页计数 Tag 实时反映新上限。 + +### 9.8.6 本轮新增/触碰文件清单 + +| 文件 | 改动 | +|------|------| +| `web/classic/src/pages/SubAccount/index.jsx` | 整页重构 + 全部上述列/弹窗/分页/行样式 | +| `web/classic/src/pages/Setting/Operation/SettingsGeneral.jsx` | 新增子账户上限输入框 | +| `dto/sub_account.go` | `SubAccountResponse.LastUsedTime`;`SubAccountBindingResponse` 补 6 字段 | +| `model/sub_account.go` | `GetLastUsedTimesByParent` | +| `controller/sub_account.go` | 列表填 `LastUsedTime`;绑定填 group/expired/model_limits/used/unlimited | +| `model/token.go` | `IsTokenNameDuplicated` | +| `controller/token.go` | `AddToken`/`UpdateToken` 名称去重校验 | + +## 9.9 M1~M3 上线前打磨轮(2026-06-11,钱包卡片 / 对公转账审核 / 审核红点) + +> 本节记录三个里程碑全部合入后、上线前对**对公转账与发票**端到端的用户逐项打磨, +> 以及给管理员加的**审核待办红点**。除标注外均为 `web/classic` 前端 + 少量后端字段/接口。 +> 计费链路与安全边界不变。**未经 Codex 复审项**:本节最后一轮 Codex 仅报 1 条 +> review_remark 迁移误报(本项目靠 AutoMigrate 增量建列,BankTransferOrder 在迁移列表内, +> 重启即建列,非问题)。 + +### 9.9.1 钱包页卡片布局(`components/topup/index.jsx` + 卡片) + +- **对公转账 / 增值税发票置于「账户充值 / 邀请奖励」上方**,两卡左右布局 + (`grid lg:grid-cols-2 items-start gap-4 md:gap-6`,间距对齐个人设置), + 外层用 `enterprise_status===2` 把关,非企业用户不渲染、无留白。 +- **卡片头部统一为「圆形图标 + 标题 + 说明小字」**(对齐账户充值/邀请奖励): + 对公转账用 `indigo` + Landmark,发票用 `orange` + ReceiptText(底色避开已用的 blue/green); + 去掉旧的「企业专属」Tag 与 `Title` 标题样式。 +- **对公转账卡片改竖排**:上=收款信息、中=提交订单、下=转账记录(不再左右分栏)。 + 收款信息(开户行/账号等长值)`break-all` **完整换行显示**,不再省略号截断。 + 提交区内部左右两栏:左=转账金额+备注、右=回执上传+提交按钮。 +- **必填红星**:对公转账「转账金额(元)」「转账回执」、发票「开票金额/抬头/税号/接收邮箱」 + 标签后加红色 `*`(同实名认证样式)。 +- **发票卡片**:可开票额度改为**顶部整宽高亮条**(浅灰圆角),表单移到下方铺满。 + +### 9.9.2 确认入账弹窗与计费口径(`pages/BankTransfer/TransferTab.jsx` + 后端) + +- **额度按展示类型显示**:预计入账额度用 `renderQuota`(CNY→¥),不再写死 `$`。 +- **文案**:「申报金额」全部改「转账金额」;输入框标签改「用户账户充值额度(元)」; + 说明改「根据实际签署合同确定入账金额,可能高于转账金额」。 +- **入账备注**:`bank_transfer_orders` 新增 `review_remark` 列(AutoMigrate 建列), + 审批事务内落库;弹窗加「入账备注(选填)」(BD/合同/折扣),管理员列表到账金额下方灰字展示。 + `BankTransferApproveRequest` 接收 `review_remark`,`ApproveBankTransferOrder` 增参。 +- **充值流水口径**(`model/bank_transfer.go` 审批事务):`TopUp.Amount` 存 **quota 单位** + (账单 `renderQuota` 直接渲染,体现修正后入账额度),`TopUp.Money` 存**用户原始转账金额** + (`order.AmountFen`,即支付金额)。审批日志格式对齐支付宝/微信直连: + `充值额度: ,支付金额: <元>(审核人 ID: x)`(FormatQuotaShort 2 位小数无浮点噪音)。 +- **可开票额度按实付累加**(`model/bank_transfer.go`/`model/invoice.go` 3 处): + `SumUserApprovedBankTransferFen` 及开票/提交权威复核从 `credited_fen` 改 **`amount_fen`**。 + 理由:折扣/合同场景入账额度可能高于实付,但增值税发票只能按用户**实付**金额开具。 + +### 9.9.3 发票信息记忆 + 记录表封顶 + +- **上次开票信息默认填入**(按用户隔离、跨登录持久):`model.GetUserLastInvoiceRequest` + 取该用户最近一条申请,`GetInvoiceQuota` 附带 `last_invoice_type/title/tax_no/email`; + 前端以 `prev||last||回退` 默认填入,不覆盖正在编辑值。纯读库、无新表、不存敏感缓存。 +- **记录表封顶**:转账/开票记录 `>10` 条限高约 420px + 垂直滚动条(`scroll.y`), + 拉取 `page_size` 提到 50(原 10 条永远触发不了滚动)。 + +### 9.9.4 管理员审核待办红点(新功能) + +- **后端**:4 个 `CountPending*`(KYC/企业/转账/发票)+ 聚合接口 + `GET /api/user/review/pending_counts`(AdminAuth),返回各项及 `bank_transfer_total=转账+发票`。 +- **前端**:hook `useReviewPendingCounts`(**仅管理员**轮询、30s、后台标签暂停、监听 + `review:changed` 即时刷新)。侧边栏「实名认证/企业认证/对公转账」加红圈**未审核数** + (0 不显示;对公转账=转账+发票合计),对公转账页内「转账审核/发票审核」页签也各加红圈。 +- **语义**:红点按 **`status=pending`(未审核)** 计数,与工单的"未读"不同——已读未审核仍计红, + 审批通过/拒绝后才消失。 +- **性能**:风险远低于工单未读(工单全员轮询,此处仅管理员);并给 4 张表 `status` 列加 `index` + 彻底消除 COUNT 扫描隐患(AutoMigrate 建索引)。 +- 审批通过/拒绝/开具成功后 `dispatchEvent('review:changed')`,红点即时下降不必等轮询。 + +### 9.9.5 重置认证限超管 + +- 实名认证/企业认证列表「重置」按钮加 `isRoot()` 仅超管可见;后端 `/kyc|enterprise/admin/:id/reset` + 叠加 `middleware.RootAuth()` 强制(普通管理员直连 API 也 403)——前端隐藏只是体验,后端才是边界。 + +### 9.9.6 本轮触碰文件 + +后端:`controller/bank_transfer.go`/`invoice.go`、`dto/bank_transfer.go`/`invoice.go`、 +`model/bank_transfer.go`/`invoice.go`/`user_kyc.go`/`user_enterprise.go`、`router/api-router.go`。 +前端:`components/topup/index.jsx`/`BankTransferCard.jsx`/`InvoiceCard.jsx`、 +`pages/BankTransfer/index.jsx`/`TransferTab.jsx`/`InvoiceTab.jsx`、`pages/KYC/index.jsx`、 +`pages/Enterprise/index.jsx`、`components/layout/SiderBar.jsx`、`hooks/common/useReviewPendingCounts.js`(新增)。 diff --git a/docs/enterprise-payment-channel-research.md b/docs/enterprise-payment-channel-research.md new file mode 100644 index 00000000000..734dde73d10 --- /dev/null +++ b/docs/enterprise-payment-channel-research.md @@ -0,0 +1,192 @@ +# 企业公对公转账/收款通道调研 + +> 调研日期:2026-06-08 +> 背景:本平台(AI API 网关/SaaS)未来将引入企业客户,需要支持「企业公对公付费/收款」。本调研在**明确排除第三方聚合代付机构**(担心持牌方跑路、资金安全)的前提下,评估各类落地方案。 +> 方法:多来源并行检索 + 对抗式验证(每条关键结论经 3 票交叉验证,需 2/3 反驳才否决),共抓取 18 个来源、提取 40 条事实、验证 25 条、确认 23 条、否决 2 条。 +> 阅读提示:本文每条核心结论都标注了**验证票数**与**来源等级**(primary=银行/央行一手 / secondary=行业媒体 / blog=技术博客)。带 ⚠️ 的为**未取得证据的空缺项**,需线下补充。 + +--- + +## 0. 一句话结论 + +> **对一家排除第三方聚合的正规公司,「银企直连」(又称银企互联/银企直联)是唯一现实且推荐的主路线。** 先用「账务查询 + 电子回单」类**只读接口**做对公收款自动对账(风险低、资质轻),稳定后再申请**对外付款接口**做退款/返佣代付。银联/网联企业通道这轮未取得可靠证据,且从机制上中小企业基本无法绕开持牌中间方直接接入,不建议作为主路线。 + +--- + +## 1. 需求与资金流向定位 + +在前置讨论中已确认,真正要做的资金流向是 **银行对公 → 对公**(企业客户从其对公银行账户付款到我司对公账户;后续我司也可能向企业客户对公账户付款,如退款/返佣)。 + +三种"公对公"的可行性对照(前置结论,本调研的出发点): + +| 场景 | 支付宝 | 微信支付 | 说明 | +|------|--------|----------|------| +| 客户付款 → 结算进我司对公银行账户(标准商户收款) | ✅ | ✅ | 但收款方是"商户结算账户",非任意对公转账 | +| 企业支付宝账户 → 企业支付宝账户(平台内划转) | ✅ | ❌ | 仅支付宝;微信商家转账收款方只能个人 | +| **A 公司银行对公账户 → B 公司银行对公账户(真·公对公)** | ❌ | ❌ | 支付宝转对公会**原路退回**;微信商家转账只支持个人储蓄卡 | + +**所以本需求无法基于支付宝/微信支付开放接口实现,必须走银行体系。** + +--- + +## 2. 方案一:银企直连(推荐主路线) + +### 2.1 什么是银企直连,与普通企业网银的区别 + +**银企直连(银企互联/银企直联)** 的本质:企业把自身的财务/ERP/资金管理系统通过 **API 报文**直接对接银行核心/现金管理系统,在**自己的系统内部**完成账户查询、明细查询、自动转账、付款审批与自动对账,**无需人工登录企业网银网页逐笔操作**。 + +| | 普通企业网银 | 银企直连 | +|---|---|---| +| 操作方式 | 人工登录网页、插 UKey、逐笔录入 | 系统内 API 自动完成 | +| 适用规模 | 笔数少、手工可控 | 高频、批量、需自动化/自动对账 | +| 对账 | 人工下载流水 + 人工比对 | 自动拉流水 + 自动匹配账目 | + +- 验证:**3-0 通过**(来源:treasurychina、合思 ekuaibao、平安银行官网) +- 来源等级:含 1 条 primary(平安官网) + +### 2.2 部署形态:前置机、专线都是"可选"而非强制(关键利好) + +银企直连**底层就是 API 报文对接**;前置机和专线是**可选的部署/网络形态**,不是必需品。主流接入分三类: + +| 模式 | 形态 | 特点 | 适合 | +|------|------|------|------| +| 常用模式 | 前置机 + U盾 | 并发低;U盾 1–2 年需更换 | 传统中大型企业 | +| 高频模式 | 加密机替代 U盾 | 性能提升 10 倍+,但硬件成本高 | 超大型集团/互联网公司 | +| **无前置模式** | **软证书,免前置机** | 省硬件、对云平台友好 | **建设成本敏感的中小公司** ✅ | + +网络层:**公网**(成本低,受外网波动)或**专线**(稳定,但需月/年租、较贵、申请约 1 个月)。 + +> 对我们的意义:**选「无前置 + 公网 + 软证书」即可起步**,不必采购前置机/加密机或拉专线,显著降低门槛。 + +- 验证:**3-0 通过**(来源:treasurychina、CSDN 深度文、平安官网、cnblogs;含光大银行实际接入案例:HTTP 调用前置机本地地址 `http://172.16.220.165:8000/ent/...do`,返回码 `0000` 表示成功) +- ❗ 一条相关 claim **被否决(1-2)**:"前置机是银行提供的物理机、企业程序不能直连银行后台"——此说法**不成立**,前置机非强制、可免前置直连。 + +### 2.3 各银行能力对照(已验证部分) + +| 银行 | 接入方式 | 对本平台的适配点 | 验证 | +|------|----------|------------------|------| +| **工商银行** | 开放平台:`P0015 账务查询`(余额/当日明细/历史明细)+ 交易电子回单(含验证码,可工行官网验真、作付款凭证) | **只读接口直接支撑「对公收款自动对账」**,资质要求轻,适合第一阶段 | 3-0,primary(open.icbc.com.cn) | +| **招商银行** | ① 传统 CBS8 / CBSLink(前置机,底层仍是 OpenAPI,**前置机可选**)② **云直联**(总对总 SaaS,**免前置机/免专线**,在财务系统界面内完成账户查询/转账/资金归集/数据下载)③ 独立 Open API 平台 `openapi.cmbchina.com`(需实名 + 企业认证) | 云直联最轻量;支持**回单/明细 webhook 实时回传**,自动入账体验最好 | 3-0,含 2 条 primary 官方域名 | +| **平安银行** | **前置机模式**(B2BiC 客户端代理)+ **免前置模式**(集成 SDK 或自研签名加密,API 调用);报文 = 二进制报文头(企业代码/交易码)+ XML 报文体,TCPClient 通信;企业网银电子对账 | 免前置模式适合中小公司自研接入 | 3-0,含 primary 官网 | + +**平安开通流程**(可作为各行流程参考):填《银企直联服务使用申请表》加盖印鉴 → 开户行审核 → 上级分行审批 → 银行信息科技部与企业技术人员联调。 + +> ⚠️ **未覆盖银行**:建设银行、中信银行、**宁波银行、网商银行(蚂蚁)** 的具体能力本轮未取得证据。其中宁波银行、网商银行的"开放银行"在中小企业自助接入上可能比传统大行更轻量,**值得作为补充调研重点**(见 §6)。 + +### 2.4 对公收款自动对账(第一阶段核心能力) + +银企直连可**自动拉取银行流水并与系统账目自动匹配**,替代"人工下载流水 + 人工比对",从而支撑对公收款的自动入账识别。部分银行(如招行)支持**回单/明细 webhook 实时回传**。 + +- 验证:**3-0 通过**(来源:合思 ekuaibao、工行开放平台) +- ⚠️ **重要限定**:"自动对账"≠零人工。以下仍需人工兜底:未直联的账户、未达账项、记账维度不一致、接口运维巡检、备注/附言不规范导致无法自动匹配的入账。 + +### 2.5 跨行统一标准?(本需求的重要问号) + +研究问题中关心的"**是否存在中国银联银企互联 / CNAPS 银企互联报文标准,使一套接口连多家银行**"——⚠️ **本轮未取得任何证据**,结论空缺。 + +> 现实预期(待验证):各行银企直连报文格式、交易码、证书体系**各不相同**,一套代码连多家行通常需要**逐行适配**(类似本项目 relay 层多 provider adapter 的模式)。是否有统一报文标准能降低这部分成本,需向银行或银企直连服务商进一步确认。 + +--- + +## 3. 方案二:银联 / 网联企业支付通道(不推荐作为主路线) + +⚠️ **本轮调研未取得关于以下问题的可靠证据**(抓取到的银联/网联来源经验证均为 unreliable,贡献 0 条确认 claim): + +- 银联无卡快捷 / 银联企业网银支付 / 云闪付企业版是否支持公对公收付款; +- 网联清算在企业支付中的角色,中小企业能否直接接入还是必须经持牌机构。 + +**基于机制的推定结论**(非证据,供决策参考): +- 网联本身是**清算机构**,不直接面向普通企业开放;中小企业接入通常需经**持牌支付机构或银行**。 +- 银联/网联的零售支付产品收款方多为个人/银行卡,公对公并非其标准能力。 +- 由于本需求**明确排除持牌中间方**,这条路大概率绕回"要么经第三方、要么本质还是银行直连",**不建议作为主路线**。 + +--- + +## 4. 合规框架(硬成本,务必纳入预算) + +央行金融行业标准 **JR/T 0185-2020《商业银行应用程序接口安全管理规范》**(2020-02-13 发布,现行有效)规范开放银行 API 全生命周期安全,**适用于银行及"集成接口服务的应用方"(即我司)**: + +- **技术对接两种方式**:API 直连,或银行 SDK 间接连接。 +- **对公收付款属最高安全级 A2(资金交易类)接口**,应用方身份认证必须使用 **数字证书 / 公私钥对做双向认证**。 +- 应用方须满足国家 **网络安全等级保护(等保)** 要求。 +- **日志留存不少于 6 个月**(应用/网络/主机/安全产品日志)。 + +- 验证:**3-0 通过**,来源:央行官网 PDF(primary) +- ❗ 一条相关 claim **被否决(1-2)**:"A2 接口用户登录须双因子认证"作为硬性要求**未获多数验证**。应用方侧的硬性要求以已确认的"**数字证书/公私钥双向认证**"为准。 +- ⚠️ 该标准为**推荐性**;2024《网络数据安全管理条例》等新规可能叠加更严的数据安全/留存要求,合规成本可能上升。具体以开户行最新口径为准。 + +> **结论:接入不只是写代码。** 还需:① 过等保 ② 配数字证书 ③ 日志留存 ≥6 个月。这是中小公司接入的隐性合规与运维成本。 + +--- + +## 5. 关键数据缺口(公开资料查不到,必须线下问) + +以下两项**网上无可信公开数据,必须向开户行客户经理一对一询价**: + +1. **费率成本**:单笔交易费、年费、专线月租、前置机/加密机硬件成本、开发集成报价。 +2. **到账时效**:对公收/付款是实时 / T+0 / T+1。 + +建议向 **工行 / 招行 / 平安 / 宁波银行 / 网商银行** 逐一询价对比。 + +--- + +## 6. 分阶段落地建议 + +### 阶段一:对公收款自动对账(先做,低风险) + +- **接口**:工行 `P0015 账务查询` + 电子回单,或招行云直联 webhook 回单回传(**全是只读接口**,不碰资金出账,资质审批轻)。 +- **系统侧逻辑**: + 1. 轮询 / 接收银行流水; + 2. 按 **金额 + 附言/备注里的订单号或客户编码** 匹配到对应企业用户; + 3. 自动给该用户充值 / 记账; + 4. 未匹配的入**人工核对队列**。 +- **与本项目现有能力衔接**:复用当前分支的企业实名/企业证书模块(`controller/enterprise.go`、`model/user_enterprise.go`、`docs/enterprise-cert-design.md`),把"企业对公账户信息"纳入企业资料,作为对账匹配与充值入账的主体标识。 +- ⚠️ 保留人工兜底通道(未达账项、附言不规范等)。 + +### 阶段二:对公付款 / 代付(稳定后再做) + +- 申请开通**对外付款接口**(资质审批更重、数字证书等级更高)。 +- 系统侧需要:**幂等** + 付款**状态机**(待付款/处理中/成功/失败/退票)+ **双人复核**。 +- 设计上抽象成 `payout`/`disbursement` 通道接口(对齐本项目 relay 层多 provider adapter 风格),底层可按行扩展。 + +--- + +## 7. 待补充的开放问题(建议第二轮调研 / 线下确认) + +1. 各行银企直连 / 开放 API 的**实际费率与到账时效**(逐家询价)。 +2. 是否存在**银联银企互联 / CNAPS 跨行统一报文标准**,能否一套接口连多家行;若有,门槛与持牌要求。 +3. **银联无卡快捷 / 云闪付企业版 / 网联**是否支持公对公,中小公司能否直接接入。 +4. **只读接口 vs 付款接口**在开通门槛、资质审批、数字证书等级上的具体差异。 +5. **宁波银行、网商银行(蚂蚁)** 开放银行 API 在中小企业/SaaS 自助接入场景下是否更轻量。 + +--- + +## 8. 来源清单 + +### 一手来源(primary) +- 中国人民银行 JR/T 0185-2020《商业银行应用程序接口安全管理规范》— https://www.pbc.gov.cn/zhengwugongkai/4081330/4406346/4693549/4085095/2020030414545789741.pdf +- 工商银行开放平台 · 账户管理/账务查询 — https://open.icbc.com.cn/icbc/apip/account.html +- 招商银行 Open API 平台 — https://openapi.cmbchina.com/ +- 平安银行 · 银企直联 — https://bank.pingan.com/gongsi/dianziyinhang/yinqizhilian.shtml + +### 行业/二手来源(secondary) +- 财资中国(treasurychina)· 各行开放银行 API 接入 — https://www.treasurychina.com/post/8027.html +- 集简云 · 招行云直联说明 — https://www.jijyun.cn/help/detail/845 + +### 技术博客来源(blog,多源互证) +- CSDN · 招行 CBS8/CBSLink 与 OpenAPI 接入 — https://blog.csdn.net/dwjnhkbc123/article/details/142362215 +- CSDN · 平安银企直联报文/开通流程 — https://blog.csdn.net/zhgl7688/article/details/123419473 +- CSDN · 银企直连前置机/三模式 — https://blog.csdn.net/Hana335566/article/details/122966303 +- 合思 ekuaibao · 银企直连自动对账 — https://www.ekuaibao.com/blog/172785.html + +### 前置(支付宝/微信公对公可行性) +- 微信支付和支付宝如何公对公转账?— https://www.zhihu.com/question/48917111 +- 支付宝单笔转账接口 alipay.fund.trans.uni.transfer — https://opendocs.alipay.com/open/c9d43874_alipay.fund.trans.uni.transfer + +--- + +## 9. 验证与可信度说明 + +- 本调研对 25 条 claim 做了 3 票对抗式验证,**确认 23 条、否决 2 条**(否决项已在正文标注)。 +- **源质量局限**:部分核心技术细节(前置机架构、报文格式、三模式分类)依赖 CSDN/知乎/集成商博客等二级源,虽多源互证但非银行一手规范;平安/工行/招行官方页面抓取时被网络策略拦截,验证依赖搜索引擎索引的官方页面快照(自洽但非直接抓取)。 +- **时效性**:银行产品名与接入政策可能随时间变化,**最终以开户行最新口径为准**;费率与到账时效**必须线下询价确认**。 diff --git a/dto/bank_transfer.go b/dto/bank_transfer.go new file mode 100644 index 00000000000..fb3ea8c7370 --- /dev/null +++ b/dto/bank_transfer.go @@ -0,0 +1,55 @@ +package dto + +import "time" + +// 对公转账充值 DTO(docs/enterprise-features-design.md §2.4)。 +// 所有金额字段单位为「分」(D1),字段名一律带 Fen 后缀。 + +type BankTransferSubmitRequest struct { + AmountFen int64 `json:"amount_fen" binding:"required,gt=0"` + Remark string `json:"remark" binding:"max=255"` + Receipt string `json:"receipt"` // 转账回执图片 base64(必传,handler 校验) +} + +type BankTransferApproveRequest struct { + CreditedFen int64 `json:"credited_fen"` // 实际到账金额(分);0/缺省 = 按申报金额入账 + ReviewRemark string `json:"review_remark" binding:"max=255"` // 入账备注(如 BD/合同/折扣约定) +} + +type BankTransferRejectRequest struct { + Reason string `json:"reason" binding:"required,max=255"` +} + +// BankTransferConfigResponse 用户侧收款信息。未启用或用户未通过企业认证时仅返回 enabled=false。 +type BankTransferConfigResponse struct { + Enabled bool `json:"enabled"` + CompanyName string `json:"company_name,omitempty"` + PayeeName string `json:"payee_name,omitempty"` + AccountNumber string `json:"account_number,omitempty"` + BankName string `json:"bank_name,omitempty"` + MinAmountFen int64 `json:"min_amount_fen,omitempty"` + Tips string `json:"tips,omitempty"` +} + +type BankTransferAdminItem struct { + Id int `json:"id"` + UserId int `json:"user_id"` + Username string `json:"username"` + AmountFen int64 `json:"amount_fen"` + CreditedFen int64 `json:"credited_fen"` + QuotaGranted int64 `json:"quota_granted"` + Remark string `json:"remark,omitempty"` + TradeNo string `json:"trade_no"` + Status int `json:"status"` + ReviewRemark string `json:"review_remark,omitempty"` + RejectReason string `json:"reject_reason,omitempty"` + ReviewedBy int `json:"reviewed_by,omitempty"` + ReviewerName string `json:"reviewer_name,omitempty"` + HasReceipt bool `json:"has_receipt"` + SubmittedAt *time.Time `json:"submitted_at,omitempty"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` +} + +type BankTransferReceiptResponse struct { + ReceiptImage string `json:"receipt_image"` // data:image/jpeg;base64,... +} diff --git a/dto/invoice.go b/dto/invoice.go new file mode 100644 index 00000000000..8f3cb20efb0 --- /dev/null +++ b/dto/invoice.go @@ -0,0 +1,59 @@ +package dto + +import "time" + +// 增值税发票 DTO(docs/enterprise-features-design.md §三)。 +// 金额字段单位「分」(D1),字段名带 Fen 后缀。 + +type InvoiceSubmitRequest struct { + AmountFen int64 `json:"amount_fen" binding:"required,gt=0"` + InvoiceType int `json:"invoice_type" binding:"required,oneof=1 2"` // 1=增值税普通发票 2=增值税专用发票 + Title string `json:"title" binding:"required,max=128"` + TaxNo string `json:"tax_no" binding:"required,max=32"` + Email string `json:"email" binding:"required,email,max=128"` + Remark string `json:"remark" binding:"max=255"` +} + +type InvoiceRejectRequest struct { + Reason string `json:"reason" binding:"required,max=255"` +} + +// InvoiceIssueRequest 管理员开具:上传发票文件(base64)。 +type InvoiceIssueRequest struct { + FileName string `json:"file_name" binding:"required,max=128"` + FileData string `json:"file_data" binding:"required"` // base64(PDF/JPG/PNG) +} + +// InvoiceQuotaResponse 可开票额度 + 抬头预填信息。 +type InvoiceQuotaResponse struct { + AvailableFen int64 `json:"available_fen"` + CompanyName string `json:"company_name,omitempty"` // 企业认证的公司名称,前端预填抬头 + // 上次提交的开票信息(按用户隔离、跨登录持久),供前端默认填入;无历史时各字段为空 + LastInvoiceType int `json:"last_invoice_type,omitempty"` + LastTitle string `json:"last_title,omitempty"` + LastTaxNo string `json:"last_tax_no,omitempty"` + LastEmail string `json:"last_email,omitempty"` +} + +type InvoiceAdminItem struct { + Id int `json:"id"` + UserId int `json:"user_id"` + Username string `json:"username"` + AmountFen int64 `json:"amount_fen"` + InvoiceType int `json:"invoice_type"` + Title string `json:"title"` + TaxNo string `json:"tax_no"` + Email string `json:"email"` + Remark string `json:"remark,omitempty"` + Status int `json:"status"` + RejectReason string `json:"reject_reason,omitempty"` + ReviewedBy int `json:"reviewed_by,omitempty"` + ReviewerName string `json:"reviewer_name,omitempty"` + SubmittedAt *time.Time `json:"submitted_at,omitempty"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` +} + +type InvoiceFileResponse struct { + FileName string `json:"file_name"` + FileData string `json:"file_data"` // base64 +} diff --git a/dto/sub_account.go b/dto/sub_account.go new file mode 100644 index 00000000000..fc304b28c45 --- /dev/null +++ b/dto/sub_account.go @@ -0,0 +1,53 @@ +package dto + +// 企业子账户相关 DTO(docs/enterprise-features-design.md 功能C)。 + +// CreateSubAccountRequest 创建子账户请求。用户名/密码格式规则与主站注册一致。 +type CreateSubAccountRequest struct { + Username string `json:"username" validate:"required,max=20"` + Password string `json:"password" validate:"required,min=8,max=20"` + DisplayName string `json:"display_name" validate:"max=20"` +} + +// ResetSubAccountPasswordRequest 重置子账户密码请求。 +type ResetSubAccountPasswordRequest struct { + Password string `json:"password" validate:"required,min=8,max=20"` +} + +// SetSubAccountStatusRequest 启用/禁用子账户请求(status:1=启用 2=禁用,复用 users.status)。 +type SetSubAccountStatusRequest struct { + Status int `json:"status"` +} + +// SubAccountTokenRequest 绑定/解绑令牌请求(子账户 id 走路径参数)。 +type SubAccountTokenRequest struct { + TokenId int `json:"token_id"` +} + +// SubAccountResponse 子账户列表项。 +type SubAccountResponse struct { + Id int `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Status int `json:"status"` + BindingCount int `json:"binding_count"` + CreatedAt int64 `json:"created_at"` + LastUsedTime int64 `json:"last_used_time"` // 绑定令牌中最近一次使用时间(秒);无绑定/从未使用为 0 +} + +// SubAccountBindingResponse 某子账户的绑定令牌项(含令牌名与明文 key,D11)。 +type SubAccountBindingResponse struct { + Id int `json:"id"` + TokenId int `json:"token_id"` + TokenName string `json:"token_name"` + TokenKey string `json:"token_key"` + RemainQuota int `json:"remain_quota"` + UsedQuota int `json:"used_quota"` + UnlimitedQuota bool `json:"unlimited_quota"` + Status int `json:"status"` + Group string `json:"group"` + ExpiredTime int64 `json:"expired_time"` + ModelLimitsEnabled bool `json:"model_limits_enabled"` + ModelLimits string `json:"model_limits"` + CreatedAt int64 `json:"created_at"` +} diff --git a/i18n/keys.go b/i18n/keys.go index ae492ddc180..f7ed9f48e0d 100644 --- a/i18n/keys.go +++ b/i18n/keys.go @@ -332,6 +332,11 @@ const ( MsgKycImageUploadFailed = "kyc.image_upload_failed" ) +// Sub-account related messages +const ( + MsgSubAccountForbidden = "sub_account.forbidden" +) + // Custom OAuth provider related messages const ( MsgCustomOAuthNotFound = "custom_oauth.not_found" diff --git a/i18n/locales/en.yaml b/i18n/locales/en.yaml index 40188ca42dd..b07bdec2f8d 100644 --- a/i18n/locales/en.yaml +++ b/i18n/locales/en.yaml @@ -287,3 +287,5 @@ kyc.images_incomplete: "Please upload both the front and back of the ID card." kyc.images_not_found: "No ID card images for this KYC record." kyc.sensitive_access_denied: "You are not allowed to view sensitive info at this KYC status." kyc.image_upload_failed: "Failed to upload ID card image." + +sub_account.forbidden: "Sub-accounts are not allowed to perform this action." diff --git a/i18n/locales/zh-CN.yaml b/i18n/locales/zh-CN.yaml index 682c775c291..9b047c78544 100644 --- a/i18n/locales/zh-CN.yaml +++ b/i18n/locales/zh-CN.yaml @@ -288,3 +288,5 @@ kyc.images_incomplete: "请同时上传身份证正面和背面图片。" kyc.images_not_found: "该 KYC 记录暂无身份证图片。" kyc.sensitive_access_denied: "当前 KYC 状态下您无权查看敏感信息。" kyc.image_upload_failed: "身份证图片上传失败。" + +sub_account.forbidden: "子账户无权进行此操作。" diff --git a/i18n/locales/zh-TW.yaml b/i18n/locales/zh-TW.yaml index b1b7dffdda6..f6041e44709 100644 --- a/i18n/locales/zh-TW.yaml +++ b/i18n/locales/zh-TW.yaml @@ -288,3 +288,5 @@ kyc.images_incomplete: "請同時上傳身分證正面和背面圖片。" kyc.images_not_found: "該 KYC 記錄暫無身分證圖片。" kyc.sensitive_access_denied: "當前 KYC 狀態下您無權查看敏感資訊。" kyc.image_upload_failed: "身分證圖片上傳失敗。" + +sub_account.forbidden: "子帳戶無權進行此操作。" diff --git a/middleware/sub_account.go b/middleware/sub_account.go new file mode 100644 index 00000000000..66540d6bc0c --- /dev/null +++ b/middleware/sub_account.go @@ -0,0 +1,59 @@ +package middleware + +import ( + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/i18n" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/types" + + "github.com/gin-gonic/gin" +) + +// SubAccountForbidden 黑名单中间件:挂在子账户「不允许」碰的写入口上 +// (充值/令牌写/兑换/签到/邀请/认证/对公转账/发票写/子账户管理/绘图日志等)。 +// +// 子账户的本质是「隶属于某企业的只读视图」(users.parent_user_id > 0)。这是服务端 +// 强制的安全边界,前端隐藏入口只是体验优化。管理员/超管恒放行(role>=Admin 本不会是子账户)。 +// +// 与 KYCRequired 同构地兼容两条鉴权链路:优先读 TokenAuth 写入的 context key, +// UserAuth 链路 miss 时回源 userCache(GetUserCache 已实时回源 DB,主账户把某用户 +// 改成子账户也能即时生效)。 +func SubAccountForbidden() gin.HandlerFunc { + return func(c *gin.Context) { + role := readUserRole(c) + if role >= common.RoleAdminUser { + c.Next() + return + } + if readUserParentId(c) > 0 { + abortWithOpenAiMessage(c, http.StatusForbidden, + common.TranslateMessage(c, i18n.MsgSubAccountForbidden), + types.ErrorCodeAccessDenied) + return + } + c.Next() + } +} + +// readUserParentId mirrors readUserEnterpriseStatus: prefer the TokenAuth-written +// context key, fall back to a userCache lookup on the UserAuth path. Returns 0 +// (not a sub-account) when no user id is set or cache lookup fails. +func readUserParentId(c *gin.Context) int { + if v, ok := c.Get(string(constant.ContextKeyUserParentId)); ok { + if parentId, ok := v.(int); ok { + return parentId + } + } + userId := c.GetInt("id") + if userId <= 0 { + return 0 + } + userCache, err := model.GetUserCache(userId) + if err != nil { + return 0 + } + return userCache.ParentUserId +} diff --git a/model/bank_transfer.go b/model/bank_transfer.go new file mode 100644 index 00000000000..86b671fe607 --- /dev/null +++ b/model/bank_transfer.go @@ -0,0 +1,351 @@ +package model + +import ( + "errors" + "fmt" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/shopspring/decimal" + "gorm.io/gorm" +) + +// 对公转账充值(docs/enterprise-features-design.md §二)。 +// 金额一律以「分」存整数(D1);回执图片独立表 + AES-256-GCM 加密(D2); +// 审批入账复用 ManualCompleteTopUp 的行锁 + 幂等模式,并在同一事务内写 topups 流水。 + +const ( + BankTransferStatusPending = 1 + BankTransferStatusApproved = 2 + BankTransferStatusRejected = 3 + + // BankTransferMaxAmountFen 单笔金额业务上限(分)= ¥100 亿,与前端 10 位整数限制 + // 对齐。该上限下折算 quota 最大约 6.9e14,距 int64 上限仍有 4 个数量级, + // 杜绝直连 API 提交天文数字导致 decimal.IntPart() 溢出产生垃圾额度。 + BankTransferMaxAmountFen = int64(1_000_000_000_000) +) + +var ( + ErrBankTransferNotFound = errors.New("转账订单不存在") + ErrBankTransferNotPending = errors.New("订单已处理,请刷新后查看") + ErrBankTransferHasPending = errors.New("已有待审核的转账订单,请等待审核完成") + ErrBankTransferInvalidAmount = errors.New("无效的到账金额") + ErrBankTransferAmountTooLarge = errors.New("金额超出允许范围") +) + +type BankTransferOrder struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + UserId int `json:"user_id" gorm:"index;not null"` + AmountFen int64 `json:"amount_fen" gorm:"not null"` // 用户申报转账金额(分) + CreditedFen int64 `json:"credited_fen" gorm:"default:0"` // 管理员确认的实际到账金额(分) + QuotaGranted int64 `json:"quota_granted" gorm:"default:0"` // 实际入账 quota,审批时按固定汇率折算回填 + Remark string `json:"remark" gorm:"type:varchar(255)"` + Status int `json:"status" gorm:"type:int;not null;default:1;index"` + ReviewRemark string `json:"review_remark,omitempty" gorm:"type:varchar(255)"` // 管理员入账备注(如 BD/合同/折扣约定) + RejectReason string `json:"reject_reason,omitempty" gorm:"type:varchar(255)"` + ReviewedBy int `json:"reviewed_by,omitempty" gorm:"type:int"` + TradeNo string `json:"trade_no" gorm:"type:varchar(64);uniqueIndex"` + SubmittedAt *time.Time `json:"submitted_at"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// BankTransferReceipt 转账回执图片,1:1 挂在订单上。独立表使列表查询永不触碰 +// 大字段;图片列省略 type 标签走方言默认映射(MySQL longtext),同企业认证图片表。 +type BankTransferReceipt struct { + Id int `gorm:"primaryKey;autoIncrement"` + OrderId int `gorm:"uniqueIndex;not null"` + UserId int `gorm:"index;not null"` + ReceiptEnc string `gorm:"not null"` // 回执图片(AES-256-GCM 加密 base64) + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` +} + +// CreateBankTransferOrderWithReceipt 创建订单 + 回执(事务)。 +// 同一用户最多 1 笔待审核订单,防刷单。 +func CreateBankTransferOrderWithReceipt(userId int, amountFen int64, remark string, receiptEnc string) (*BankTransferOrder, error) { + if amountFen <= 0 || amountFen > BankTransferMaxAmountFen { + return nil, ErrBankTransferAmountTooLarge + } + now := time.Now() + order := &BankTransferOrder{ + UserId: userId, + AmountFen: amountFen, + Remark: remark, + Status: BankTransferStatusPending, + TradeNo: fmt.Sprintf("BT%s%d", common.GetRandomString(6), now.Unix()), + SubmittedAt: &now, + } + err := DB.Transaction(func(tx *gorm.DB) error { + var pendingCount int64 + if err := tx.Model(&BankTransferOrder{}). + Where("user_id = ? AND status = ?", userId, BankTransferStatusPending). + Count(&pendingCount).Error; err != nil { + return err + } + if pendingCount > 0 { + return ErrBankTransferHasPending + } + if err := tx.Create(order).Error; err != nil { + return err + } + return tx.Create(&BankTransferReceipt{ + OrderId: order.Id, + UserId: userId, + ReceiptEnc: receiptEnc, + }).Error + }) + if err != nil { + return nil, err + } + return order, nil +} + +func GetBankTransferOrderById(id int) (*BankTransferOrder, error) { + var order BankTransferOrder + if err := DB.First(&order, id).Error; err != nil { + return nil, err + } + return &order, nil +} + +func GetUserBankTransferOrders(userId int, pageInfo *common.PageInfo) (orders []*BankTransferOrder, total int64, err error) { + query := DB.Model(&BankTransferOrder{}).Where("user_id = ?", userId) + if err = query.Count(&total).Error; err != nil { + return nil, 0, err + } + err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&orders).Error + if err != nil { + return nil, 0, err + } + return orders, total, nil +} + +// CancelBankTransferOrder 用户撤销自己的待审核订单:软删订单、硬删回执。 +// 软删带 status=pending 条件做抢占(同审批路径),避免"管理员已入账、 +// 用户并发撤销把订单删掉"的不一致:审批先赢则这里 RowsAffected=0 直接报错。 +func CancelBankTransferOrder(userId int, id int) error { + return DB.Transaction(func(tx *gorm.DB) error { + var order BankTransferOrder + if err := tx.Where("id = ? AND user_id = ?", id, userId).First(&order).Error; err != nil { + return ErrBankTransferNotFound + } + res := tx.Where("id = ? AND user_id = ? AND status = ?", id, userId, BankTransferStatusPending). + Delete(&BankTransferOrder{}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrBankTransferNotPending + } + return tx.Unscoped().Where("order_id = ?", id).Delete(&BankTransferReceipt{}).Error + }) +} + +func GetBankTransferReceipt(orderId int) (*BankTransferReceipt, error) { + var receipt BankTransferReceipt + if err := DB.Where("order_id = ?", orderId).First(&receipt).Error; err != nil { + return nil, err + } + return &receipt, nil +} + +// BankTransferQuotaForFen 按固定汇率参数把到账金额(分)折算为 quota(D3)。 +// 全程 decimal 整数运算,仅最终结果取整。 +func BankTransferQuotaForFen(creditedFen int64) (int, error) { + rate := operation_setting.USDExchangeRate + if rate <= 0 { + return 0, errors.New("系统汇率参数无效") + } + quota := decimal.NewFromInt(creditedFen). + Div(decimal.NewFromInt(100)). + Div(decimal.NewFromFloat(rate)). + Mul(decimal.NewFromFloat(common.QuotaPerUnit)). + IntPart() + if quota <= 0 { + return 0, ErrBankTransferInvalidAmount + } + return int(quota), nil +} + +// ApproveBankTransferOrder 审批通过:条件更新抢占订单 → 写 topups 成功流水 → +// 给用户加 quota,全部在同一事务内。 +// creditedFen 为管理员确认的实际到账金额(分),controller 缺省时传申报金额。 +// +// 并发安全说明:不使用 FOR UPDATE(GORM v2 已忽略 v1 的 gorm:query_option 机制, +// 且裸 FOR UPDATE 不兼容 SQLite)。改用条件更新抢占: +// UPDATE ... WHERE id=? AND status=pending,数据库保证同一行的并发 UPDATE 串行执行, +// 只有一个事务能命中 WHERE(RowsAffected=1)并继续入账,其余返回"已处理"—— +// 原子、幂等、三库兼容(CLAUDE.md Rule 2)。 +func ApproveBankTransferOrder(id int, reviewerId int, creditedFen int64, reviewRemark string, callerIp string) error { + if creditedFen <= 0 { + return ErrBankTransferInvalidAmount + } + if creditedFen > BankTransferMaxAmountFen { + return ErrBankTransferAmountTooLarge + } + quotaToAdd, err := BankTransferQuotaForFen(creditedFen) + if err != nil { + return err + } + + var userId int + var amountFen int64 + err = DB.Transaction(func(tx *gorm.DB) error { + order := &BankTransferOrder{} + // 仅读取不可变字段(UserId/TradeNo)与存在性;状态判定交给下面的条件更新 + if err := tx.Where("id = ?", id).First(order).Error; err != nil { + return ErrBankTransferNotFound + } + + now := time.Now() + res := tx.Model(&BankTransferOrder{}). + Where("id = ? AND status = ?", id, BankTransferStatusPending). + Updates(map[string]interface{}{ + "status": BankTransferStatusApproved, + "credited_fen": creditedFen, + "review_remark": reviewRemark, + "quota_granted": int64(quotaToAdd), + "reviewed_by": reviewerId, + "reviewed_at": now, + }) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + // 已被并发的审批/拒绝/撤销处理 + return ErrBankTransferNotPending + } + + // 写统一充值流水,使对公转账出现在充值历史里 + timestamp := common.GetTimestamp() + // Amount 存 quota 单位(与支付宝/微信直连流水一致,账单弹窗 renderQuota 直接渲染); + // Money 存用户原始转账金额(支付金额),到账修正额体现在 Amount/quota_granted。 + topUp := &TopUp{ + UserId: order.UserId, + Amount: int64(quotaToAdd), + Money: common.FenToYuan(order.AmountFen), + TradeNo: order.TradeNo, + PaymentMethod: PaymentMethodBankTransfer, + PaymentProvider: PaymentProviderBankTransfer, + CreateTime: timestamp, + CompleteTime: timestamp, + Status: common.TopUpStatusSuccess, + } + if err := tx.Create(topUp).Error; err != nil { + return err + } + + if err := tx.Model(&User{}).Where("id = ?", order.UserId). + Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil { + return err + } + + userId = order.UserId + amountFen = order.AmountFen + return nil + }) + if err != nil { + return err + } + + // 事务外记录日志与缓存失效 + // 格式对齐支付宝/微信直连:FormatQuotaShort 按额度展示类型 2 位小数(无浮点噪音);支付金额=用户原始转账金额 + RecordTopupLog(userId, fmt.Sprintf("对公转账充值成功,充值额度: %v,支付金额: %.2f(审核人 ID: %d)", + logger.FormatQuotaShort(quotaToAdd), common.FenToYuan(amountFen), reviewerId), callerIp, PaymentMethodBankTransfer, PaymentProviderBankTransfer) + _ = InvalidateUserCache(userId) + return nil +} + +// RejectBankTransferOrder 审批拒绝,原因必填(controller 校验)。 +// 并发安全同 ApproveBankTransferOrder:条件更新抢占,不依赖行锁。 +func RejectBankTransferOrder(id int, reviewerId int, reason string) error { + var exists int64 + if err := DB.Model(&BankTransferOrder{}).Where("id = ?", id).Count(&exists).Error; err != nil { + return err + } + if exists == 0 { + return ErrBankTransferNotFound + } + now := time.Now() + res := DB.Model(&BankTransferOrder{}). + Where("id = ? AND status = ?", id, BankTransferStatusPending). + Updates(map[string]interface{}{ + "status": BankTransferStatusRejected, + "reject_reason": reason, + "reviewed_by": reviewerId, + "reviewed_at": now, + }) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrBankTransferNotPending + } + return nil +} + +// BankTransferAdminRow 管理员列表 JOIN 结果:订单 + 用户名 + 审核人用户名。 +type BankTransferAdminRow struct { + BankTransferOrder + Username string `gorm:"column:username"` + ReviewerName string `gorm:"column:reviewer_name"` +} + +// GetBankTransferList 管理员分页列表。status=0 表示全部;keyword 按用户名/单号模糊。 +// JOIN 仅用全小写非保留字标识符,无需方言引号(CLAUDE.md Rule 2)。 +func GetBankTransferList(status int, keyword string, page, pageSize int) ([]*BankTransferAdminRow, int64, error) { + var rows []*BankTransferAdminRow + var total int64 + + buildQuery := func() *gorm.DB { + q := DB.Model(&BankTransferOrder{}). + Joins("LEFT JOIN users u1 ON u1.id = bank_transfer_orders.user_id") + if status != 0 { + q = q.Where("bank_transfer_orders.status = ?", status) + } + if keyword != "" { + like := "%" + keyword + "%" + q = q.Where("u1.username LIKE ? OR bank_transfer_orders.trade_no LIKE ?", like, like) + } + return q + } + + if err := buildQuery().Count(&total).Error; err != nil { + return nil, 0, err + } + + query := buildQuery(). + Select("bank_transfer_orders.*, u1.username AS username, u2.username AS reviewer_name"). + Joins("LEFT JOIN users u2 ON u2.id = bank_transfer_orders.reviewed_by") + + offset := (page - 1) * pageSize + if err := query.Order("bank_transfer_orders.id DESC").Offset(offset).Limit(pageSize).Scan(&rows).Error; err != nil { + return nil, 0, err + } + return rows, total, nil +} + +// SumUserApprovedBankTransferFen 某用户已审批通过的对公转账「转账金额」总额(分)。 +// 发票可开票额度的数据来源(D5)。注意按 amount_fen(用户实付)而非 credited_fen(入账额度)累加: +// 折扣/合同场景入账额度可能高于实付,但增值税发票只能按实付金额开具。 +func SumUserApprovedBankTransferFen(userId int) (int64, error) { + var total int64 + err := DB.Model(&BankTransferOrder{}). + Where("user_id = ? AND status = ?", userId, BankTransferStatusApproved). + Select("COALESCE(SUM(amount_fen), 0)").Scan(&total).Error + return total, err +} + +// CountPendingBankTransfer 待审核对公转账订单数(status=待审核)。 +func CountPendingBankTransfer() (int64, error) { + var n int64 + err := DB.Model(&BankTransferOrder{}). + Where("status = ?", BankTransferStatusPending).Count(&n).Error + return n, err +} diff --git a/model/invoice.go b/model/invoice.go new file mode 100644 index 00000000000..09bbbe8493b --- /dev/null +++ b/model/invoice.go @@ -0,0 +1,346 @@ +package model + +import ( + "errors" + "time" + + "github.com/QuantumNous/new-api/common" + "gorm.io/gorm" +) + +// 增值税发票(docs/enterprise-features-design.md §三)。 +// 可开票额度 = Σ已审批通过的对公转账到账金额 − Σ(待审核+已开具)发票金额(D5:仅对公转账)。 +// 金额一律「分」整数(D1);发票文件不加密(交付物,本人可随时下载,D6); +// 状态流转沿用里程碑 1 评审确定的条件更新抢占模式(不依赖 FOR UPDATE)。 + +const ( + InvoiceStatusPending = 1 + InvoiceStatusIssued = 2 + InvoiceStatusRejected = 3 + + InvoiceTypeNormal = 1 // 增值税普通发票 + InvoiceTypeSpecial = 2 // 增值税专用发票 +) + +var ( + ErrInvoiceNotFound = errors.New("发票申请不存在") + ErrInvoiceNotPending = errors.New("申请已处理,请刷新后查看") + ErrInvoiceHasPending = errors.New("已有待审核的发票申请,请等待审核完成") + ErrInvoiceQuotaExceeded = errors.New("申请金额超出可开票额度") + ErrInvoiceInvalidAmount = errors.New("无效的开票金额") +) + +type InvoiceRequest struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + UserId int `json:"user_id" gorm:"index;not null"` + AmountFen int64 `json:"amount_fen" gorm:"not null"` + InvoiceType int `json:"invoice_type" gorm:"type:int;not null;default:1"` + Title string `json:"title" gorm:"type:varchar(128);not null"` // 发票抬头 + TaxNo string `json:"tax_no" gorm:"type:varchar(32);not null"` // 税号(明文:发票要素本就交付给用户) + Email string `json:"email" gorm:"type:varchar(128);not null"` // 接收邮箱 + Remark string `json:"remark,omitempty" gorm:"type:varchar(255)"` + Status int `json:"status" gorm:"type:int;not null;default:1;index"` + RejectReason string `json:"reject_reason,omitempty" gorm:"type:varchar(255)"` + ReviewedBy int `json:"reviewed_by,omitempty" gorm:"type:int"` + SubmittedAt *time.Time `json:"submitted_at"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// InvoiceFile 发票文件(管理员开具时上传),1:1 挂在申请上。 +// FileData 省略 type 标签走方言默认映射(MySQL longtext),同回执/认证图片表的处理; +// 不加密——发票是交付给用户的文件,不是平台核验材料。 +type InvoiceFile struct { + Id int `gorm:"primaryKey;autoIncrement"` + InvoiceId int `gorm:"uniqueIndex;not null"` + UserId int `gorm:"index;not null"` + FileName string `gorm:"type:varchar(128)"` // 原始文件名(含扩展名) + FileData string `gorm:"not null"` // base64 + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` +} + +// sumUserInvoiceReservedFen 用户已占用的开票额度(待审核 + 已开具)。 +func sumUserInvoiceReservedFen(db *gorm.DB, userId int) (int64, error) { + var total int64 + err := db.Model(&InvoiceRequest{}). + Where("user_id = ? AND status IN ?", userId, []int{InvoiceStatusPending, InvoiceStatusIssued}). + Select("COALESCE(SUM(amount_fen), 0)").Scan(&total).Error + return total, err +} + +// sumUserInvoiceIssuedFen 用户已开具的发票总额。 +func sumUserInvoiceIssuedFen(db *gorm.DB, userId int) (int64, error) { + var total int64 + err := db.Model(&InvoiceRequest{}). + Where("user_id = ? AND status = ?", userId, InvoiceStatusIssued). + Select("COALESCE(SUM(amount_fen), 0)").Scan(&total).Error + return total, err +} + +// GetUserInvoiceAvailableFen 可开票额度(分)。负数按 0 返回(仅理论上的数据异常)。 +func GetUserInvoiceAvailableFen(userId int) (int64, error) { + approved, err := SumUserApprovedBankTransferFen(userId) + if err != nil { + return 0, err + } + reserved, err := sumUserInvoiceReservedFen(DB, userId) + if err != nil { + return 0, err + } + available := approved - reserved + if available < 0 { + available = 0 + } + return available, nil +} + +// CreateInvoiceRequest 提交开票申请(事务)。 +// 限制:同一用户最多 1 笔待审核;金额 ≤ 提交时点可开票额度。 +// 并发说明:两道校验与 INSERT 之间存在与"转账提交"同类的竞态窗口(已评审接受), +// 超开的资金风险由 IssueInvoice 开具时的权威额度复核兜底。 +func CreateInvoiceRequest(userId int, amountFen int64, invoiceType int, title, taxNo, email, remark string) (*InvoiceRequest, error) { + if amountFen <= 0 { + return nil, ErrInvoiceInvalidAmount + } + now := time.Now() + req := &InvoiceRequest{ + UserId: userId, + AmountFen: amountFen, + InvoiceType: invoiceType, + Title: title, + TaxNo: taxNo, + Email: email, + Remark: remark, + Status: InvoiceStatusPending, + SubmittedAt: &now, + } + err := DB.Transaction(func(tx *gorm.DB) error { + var pendingCount int64 + if err := tx.Model(&InvoiceRequest{}). + Where("user_id = ? AND status = ?", userId, InvoiceStatusPending). + Count(&pendingCount).Error; err != nil { + return err + } + if pendingCount > 0 { + return ErrInvoiceHasPending + } + + var approved int64 + if err := tx.Model(&BankTransferOrder{}). + Where("user_id = ? AND status = ?", userId, BankTransferStatusApproved). + Select("COALESCE(SUM(amount_fen), 0)").Scan(&approved).Error; err != nil { + return err + } + reserved, err := sumUserInvoiceReservedFen(tx, userId) + if err != nil { + return err + } + if amountFen > approved-reserved { + return ErrInvoiceQuotaExceeded + } + + return tx.Create(req).Error + }) + if err != nil { + return nil, err + } + return req, nil +} + +func GetInvoiceById(id int) (*InvoiceRequest, error) { + var req InvoiceRequest + if err := DB.First(&req, id).Error; err != nil { + return nil, err + } + return &req, nil +} + +// GetUserLastInvoiceRequest 返回用户最近一次开票申请(按 id 倒序),用于前端默认填入开票信息。 +// 不存在时返回 (nil, nil)。按 user_id 过滤,天然按用户隔离;持久于库,跨登录有效。 +func GetUserLastInvoiceRequest(userId int) (*InvoiceRequest, error) { + var req InvoiceRequest + err := DB.Where("user_id = ?", userId).Order("id desc").First(&req).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &req, nil +} + +func GetUserInvoices(userId int, pageInfo *common.PageInfo) (invoices []*InvoiceRequest, total int64, err error) { + query := DB.Model(&InvoiceRequest{}).Where("user_id = ?", userId) + if err = query.Count(&total).Error; err != nil { + return nil, 0, err + } + err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&invoices).Error + if err != nil { + return nil, 0, err + } + return invoices, total, nil +} + +// CancelInvoiceRequest 用户撤销自己的待审核申请:条件软删抢占(同转账撤销模式)。 +func CancelInvoiceRequest(userId int, id int) error { + return DB.Transaction(func(tx *gorm.DB) error { + var req InvoiceRequest + if err := tx.Where("id = ? AND user_id = ?", id, userId).First(&req).Error; err != nil { + return ErrInvoiceNotFound + } + res := tx.Where("id = ? AND user_id = ? AND status = ?", id, userId, InvoiceStatusPending). + Delete(&InvoiceRequest{}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrInvoiceNotPending + } + return nil + }) +} + +// IssueInvoice 管理员开具:权威额度复核 → 条件更新抢占 → 写入发票文件,同一事务内。 +// 权威复核保证 Σ(已开具) + 本笔 ≤ Σ(已通过转账),即"开出的发票永远不超过实际到账"—— +// 这是提交侧竞态(已评审接受)的资金安全兜底。 +func IssueInvoice(id int, reviewerId int, fileName string, fileData string) error { + return DB.Transaction(func(tx *gorm.DB) error { + req := &InvoiceRequest{} + if err := tx.Where("id = ?", id).First(req).Error; err != nil { + return ErrInvoiceNotFound + } + + // 按用户串行化开具:对该用户的 users 行做一次无副作用更新(quota+0), + // 在 MySQL/PG 上持有该行写锁直到事务提交,同一用户的并发开具被迫排队, + // 后到事务读到新鲜的"已开具总额",杜绝权威复核被并发穿透导致的超开。 + // 跨库安全:不使用 FOR UPDATE 语法;SQLite 单写者天然串行。users 表无 + // updated_at 自动戳,quota+0 不改变任何字段值,仅用于取锁。 + if err := tx.Model(&User{}).Where("id = ?", req.UserId). + Update("quota", gorm.Expr("quota + ?", 0)).Error; err != nil { + return err + } + + var approved int64 + if err := tx.Model(&BankTransferOrder{}). + Where("user_id = ? AND status = ?", req.UserId, BankTransferStatusApproved). + Select("COALESCE(SUM(amount_fen), 0)").Scan(&approved).Error; err != nil { + return err + } + issued, err := sumUserInvoiceIssuedFen(tx, req.UserId) + if err != nil { + return err + } + if issued+req.AmountFen > approved { + return ErrInvoiceQuotaExceeded + } + + now := time.Now() + res := tx.Model(&InvoiceRequest{}). + Where("id = ? AND status = ?", id, InvoiceStatusPending). + Updates(map[string]interface{}{ + "status": InvoiceStatusIssued, + "reviewed_by": reviewerId, + "reviewed_at": now, + }) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrInvoiceNotPending + } + + return tx.Create(&InvoiceFile{ + InvoiceId: id, + UserId: req.UserId, + FileName: fileName, + FileData: fileData, + }).Error + }) +} + +// RejectInvoice 管理员拒绝,原因必填(controller 校验)。条件更新抢占。 +func RejectInvoice(id int, reviewerId int, reason string) error { + var exists int64 + if err := DB.Model(&InvoiceRequest{}).Where("id = ?", id).Count(&exists).Error; err != nil { + return err + } + if exists == 0 { + return ErrInvoiceNotFound + } + now := time.Now() + res := DB.Model(&InvoiceRequest{}). + Where("id = ? AND status = ?", id, InvoiceStatusPending). + Updates(map[string]interface{}{ + "status": InvoiceStatusRejected, + "reject_reason": reason, + "reviewed_by": reviewerId, + "reviewed_at": now, + }) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return ErrInvoiceNotPending + } + return nil +} + +func GetInvoiceFile(invoiceId int) (*InvoiceFile, error) { + var file InvoiceFile + if err := DB.Where("invoice_id = ?", invoiceId).First(&file).Error; err != nil { + return nil, err + } + return &file, nil +} + +// InvoiceAdminRow 管理员列表 JOIN 结果。 +type InvoiceAdminRow struct { + InvoiceRequest + Username string `gorm:"column:username"` + ReviewerName string `gorm:"column:reviewer_name"` +} + +// GetInvoiceList 管理员分页列表。status=0 表示全部;keyword 按用户名/抬头模糊。 +func GetInvoiceList(status int, keyword string, page, pageSize int) ([]*InvoiceAdminRow, int64, error) { + var rows []*InvoiceAdminRow + var total int64 + + buildQuery := func() *gorm.DB { + q := DB.Model(&InvoiceRequest{}). + Joins("LEFT JOIN users u1 ON u1.id = invoice_requests.user_id") + if status != 0 { + q = q.Where("invoice_requests.status = ?", status) + } + if keyword != "" { + like := "%" + keyword + "%" + q = q.Where("u1.username LIKE ? OR invoice_requests.title LIKE ?", like, like) + } + return q + } + + if err := buildQuery().Count(&total).Error; err != nil { + return nil, 0, err + } + + query := buildQuery(). + Select("invoice_requests.*, u1.username AS username, u2.username AS reviewer_name"). + Joins("LEFT JOIN users u2 ON u2.id = invoice_requests.reviewed_by") + + offset := (page - 1) * pageSize + if err := query.Order("invoice_requests.id DESC").Offset(offset).Limit(pageSize).Scan(&rows).Error; err != nil { + return nil, 0, err + } + return rows, total, nil +} + +// CountPendingInvoice 待审核发票申请数(status=待审核)。 +func CountPendingInvoice() (int64, error) { + var n int64 + err := DB.Model(&InvoiceRequest{}). + Where("status = ?", InvoiceStatusPending).Count(&n).Error + return n, err +} diff --git a/model/log.go b/model/log.go index 4bfd061149b..ca2e716d685 100644 --- a/model/log.go +++ b/model/log.go @@ -405,13 +405,18 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName const logSearchCountLimit = 10000 -func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string) (logs []*Log, total int64, err error) { +// GetUserLogs 普通用户视角查询日志。tokenIds 非空时额外限定 token_id ∈ 集合, +// 供企业子账户「仅看绑定 key」的只读视图使用(设计 §4.5);普通用户传 nil 即不过滤。 +func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string, tokenIds []int) (logs []*Log, total int64, err error) { var tx *gorm.DB if logType == LogTypeUnknown { tx = LOG_DB.Where("logs.user_id = ?", userId) } else { tx = LOG_DB.Where("logs.user_id = ? and logs.type = ?", userId, logType) } + if len(tokenIds) > 0 { + tx = tx.Where("logs.token_id IN ?", tokenIds) + } if modelName != "" { modelNamePattern, err := sanitizeLikePattern(modelName) @@ -582,7 +587,7 @@ func ExportAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelN // ExportUserLogs 按普通用户视角流式遍历自己的日志,使用手动游标分页保证不重不漏。 // 与 GetUserLogs 一致:不回填 ChannelName、对 model_name 做 LIKE escape。 -func ExportUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, group string, requestId string, batchSize int, callback func(logs []*Log) error) error { +func ExportUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, group string, requestId string, tokenIds []int, batchSize int, callback func(logs []*Log) error) error { var modelLikePattern string if modelName != "" { pattern, err := sanitizeLikePattern(modelName) @@ -594,6 +599,9 @@ func ExportUserLogs(userId int, logType int, startTimestamp int64, endTimestamp applyFilters := func(tx *gorm.DB) *gorm.DB { tx = tx.Where("logs.user_id = ?", userId) + if len(tokenIds) > 0 { + tx = tx.Where("logs.token_id IN ?", tokenIds) + } if logType != LogTypeUnknown { tx = tx.Where("logs.type = ?", logType) } @@ -631,7 +639,9 @@ type Stat struct { Tpm int `json:"tpm"` } -func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channelIds []int, group string) (stat Stat, err error) { +// SumUsedQuota 汇总消费统计。tokenIds 非空时额外限定 token_id ∈ 集合, +// 供企业子账户自身看板/统计「仅算绑定 key」使用;其余调用方传 nil 即不过滤。 +func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channelIds []int, group string, tokenIds []int) (stat Stat, err error) { tx := LOG_DB.Table("logs").Select("sum(quota) quota") // 为rpm和tpm创建单独的查询 @@ -641,6 +651,10 @@ func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelNa tx = tx.Where("username = ?", username) rpmTpmQuery = rpmTpmQuery.Where("username = ?", username) } + if len(tokenIds) > 0 { + tx = tx.Where("token_id IN ?", tokenIds) + rpmTpmQuery = rpmTpmQuery.Where("token_id IN ?", tokenIds) + } if tokenName != "" { tx = tx.Where("token_name = ?", tokenName) rpmTpmQuery = rpmTpmQuery.Where("token_name = ?", tokenName) diff --git a/model/main.go b/model/main.go index c5bce0f5be9..751668baa28 100644 --- a/model/main.go +++ b/model/main.go @@ -284,6 +284,11 @@ func migrateDB() error { &UserKYCImage{}, &UserEnterprise{}, &UserEnterpriseImage{}, + &BankTransferOrder{}, + &BankTransferReceipt{}, + &InvoiceRequest{}, + &InvoiceFile{}, + &SubAccountTokenBinding{}, &FeedbackTopic{}, &FeedbackMessage{}, &FeedbackImage{}, @@ -340,6 +345,11 @@ func migrateDBFast() error { {&UserKYCImage{}, "UserKYCImage"}, {&UserEnterprise{}, "UserEnterprise"}, {&UserEnterpriseImage{}, "UserEnterpriseImage"}, + {&BankTransferOrder{}, "BankTransferOrder"}, + {&BankTransferReceipt{}, "BankTransferReceipt"}, + {&InvoiceRequest{}, "InvoiceRequest"}, + {&InvoiceFile{}, "InvoiceFile"}, + {&SubAccountTokenBinding{}, "SubAccountTokenBinding"}, {&FeedbackTopic{}, "FeedbackTopic"}, {&FeedbackMessage{}, "FeedbackMessage"}, {&FeedbackImage{}, "FeedbackImage"}, diff --git a/model/sub_account.go b/model/sub_account.go new file mode 100644 index 00000000000..52910107ea4 --- /dev/null +++ b/model/sub_account.go @@ -0,0 +1,337 @@ +package model + +import ( + "errors" + "fmt" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/operation_setting" + "gorm.io/gorm" +) + +// 企业子账户(docs/enterprise-features-design.md 功能C)。 +// +// 设计不变量(实现与评审都要守住): +// - 子账户只是「只读视图」,永不出现在计费链路里。绑定只是一条查看授权记录, +// 不改变 key 的所有权(tokens.user_id 不动),key 的消耗永远扣企业主账户。 +// - 「是不是子账户 / 属于谁」由 users.parent_user_id 单一字段回答(>0 即子账户)。 +// - 一个 key 至多绑给一个子账户(TokenId uniqueIndex 兜底);一个子账户可绑多个 key。 +// +// 已知并接受的竞态(评审确认 2026-06-10,接受不修): +// 1. DeleteSubAccount 在事务内校验「无绑定」后删除,但与并发的 BindTokenToSubAccount +// 属不同事务,极端交错下软删的子账户可能残留一条绑定记录; +// 2. controller 侧 DeleteToken/DeleteTokenBatch 的绑定保护检查在删除事务之外, +// 与并发 bind 交错时已删 token 可能残留绑定。 +// 两者后果均仅为父账户绑定列表出现一条可手动解绑的孤儿记录(查询侧按 user_id + +// 软删过滤兜底,不泄漏、不影响计费与资金),且为管理 UI 单人低频操作,故不引入 +// 跨表行锁串行化(对比 IssueInvoice:那里涉及资金所以上了用户行锁)。 + +// SubAccountTokenBinding 子账户↔令牌的查看授权记录。独立表而非在 tokens 上加列, +// 避免触碰上游核心表的合并面与 updated 语义。 +type SubAccountTokenBinding struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + ParentUserId int `json:"parent_user_id" gorm:"index;not null"` // 企业主账户 id(冗余存储,便于按企业批量清理与校验) + SubUserId int `json:"sub_user_id" gorm:"index;not null"` // 子账户 user_id + TokenId int `json:"token_id" gorm:"uniqueIndex;not null"` // 一个 key 最多绑给一个子账户 + CreatedAt time.Time `json:"created_at"` +} + +var ( + ErrSubAccountNotFound = errors.New("子账户不存在") + ErrSubAccountLimitReached = errors.New("子账户数量已达上限") + ErrSubAccountHasBindings = errors.New("该子账户仍有绑定的令牌,请先解除全部绑定") + ErrTokenAlreadyBound = errors.New("该令牌已绑定子账户,请先解绑") + ErrBindingNotFound = errors.New("绑定关系不存在") +) + +// CountSubAccountsByParent 统计某企业主账户名下的子账户数量(不含软删)。 +func CountSubAccountsByParent(parentId int) (int64, error) { + var count int64 + err := DB.Model(&User{}).Where("parent_user_id = ?", parentId).Count(&count).Error + return count, err +} + +// GetSubAccountsByParent 返回某企业主账户名下的全部子账户。 +func GetSubAccountsByParent(parentId int) ([]*User, error) { + var users []*User + err := DB.Where("parent_user_id = ?", parentId).Order("id desc").Find(&users).Error + return users, err +} + +// GetBindingCountsByParent 返回 parent 名下每个子账户的绑定令牌数:map[subUserId]count。 +func GetBindingCountsByParent(parentId int) (map[int]int, error) { + type row struct { + SubUserId int + Cnt int + } + var rows []row + err := DB.Model(&SubAccountTokenBinding{}). + Select("sub_user_id, count(*) as cnt"). + Where("parent_user_id = ?", parentId). + Group("sub_user_id"). + Scan(&rows).Error + if err != nil { + return nil, err + } + result := make(map[int]int, len(rows)) + for _, r := range rows { + result[r.SubUserId] = r.Cnt + } + return result, nil +} + +// GetLastUsedTimesByParent 返回 parent 名下每个子账户「绑定令牌中最近一次使用时间」的映射 +// (sub_user_id -> max(tokens.accessed_time),秒)。无绑定/从未使用的子账户不在结果里,前端按空展示。 +// 不用 JOIN:先取绑定关系,再用 token_id IN (...) 单查 accessed_time,纯 GORM 三库通用。 +func GetLastUsedTimesByParent(parentId int) (map[int]int64, error) { + type bindRow struct { + SubUserId int + TokenId int + } + var binds []bindRow + if err := DB.Model(&SubAccountTokenBinding{}). + Select("sub_user_id, token_id"). + Where("parent_user_id = ?", parentId). + Scan(&binds).Error; err != nil { + return nil, err + } + if len(binds) == 0 { + return map[int]int64{}, nil + } + tokenIds := make([]int, 0, len(binds)) + for _, b := range binds { + tokenIds = append(tokenIds, b.TokenId) + } + type tokRow struct { + Id int + AccessedTime int64 + } + var toks []tokRow + if err := DB.Model(&Token{}). + Select("id, accessed_time"). + Where("id IN ?", tokenIds). + Scan(&toks).Error; err != nil { + return nil, err + } + accessed := make(map[int]int64, len(toks)) + for _, tk := range toks { + accessed[tk.Id] = tk.AccessedTime + } + result := make(map[int]int64, len(binds)) + for _, b := range binds { + if at := accessed[b.TokenId]; at > result[b.SubUserId] { + result[b.SubUserId] = at + } + } + return result, nil +} + +// CreateSubAccount 创建一个隶属于 parentId 的只读子账户。 +// +// 与普通注册的关键差异(设计 §4.3): +// - quota=0、不发放任何注册赠送额度、不参与邀请体系(inviter_id 不写); +// - role 恒为 RoleCommonUser、status=enabled、parent_user_id=parentId。 +// +// 调用方(controller)负责:enterprise_status==2 前置校验、用户名/密码格式校验、 +// 用户名唯一性预检与数量上限校验。这里在事务内复检数量上限,防并发越限。 +func CreateSubAccount(parentId int, username, password, displayName string) (*User, error) { + hashed, err := common.Password2Hash(password) + if err != nil { + return nil, err + } + if displayName == "" { + displayName = username + } + + var created *User + err = DB.Transaction(func(tx *gorm.DB) error { + // 事务内复检上限,杜绝并发创建越过 MaxCount。 + var count int64 + if err := tx.Model(&User{}).Where("parent_user_id = ?", parentId).Count(&count).Error; err != nil { + return err + } + if count >= int64(operation_setting.GetSubAccountMaxCount()) { + return ErrSubAccountLimitReached + } + user := &User{ + Username: username, + Password: hashed, + DisplayName: displayName, + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Quota: 0, + ParentUserId: parentId, + AffCode: common.GetRandomString(4), + } + if err := tx.Create(user).Error; err != nil { + return err + } + created = user + return nil + }) + if err != nil { + return nil, err + } + return created, nil +} + +// GetSubAccount 取出归属校验后的子账户:必须存在且 parent_user_id==parentId,防越权(IDOR)。 +func GetSubAccount(subUserId, parentId int) (*User, error) { + var user User + err := DB.Where("id = ? AND parent_user_id = ?", subUserId, parentId).First(&user).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSubAccountNotFound + } + return nil, err + } + return &user, nil +} + +// ResetSubAccountPassword 重置子账户密码(调用方已做归属校验)。 +func ResetSubAccountPassword(subUserId int, newPassword string) error { + hashed, err := common.Password2Hash(newPassword) + if err != nil { + return err + } + if err := DB.Model(&User{}).Where("id = ?", subUserId).Update("password", hashed).Error; err != nil { + return err + } + return invalidateUserCache(subUserId) +} + +// SetSubAccountStatus 启用/禁用子账户(status 复用 users.status;禁用后无法登录)。 +func SetSubAccountStatus(subUserId int, status int) error { + if err := DB.Model(&User{}).Where("id = ?", subUserId).Update("status", status).Error; err != nil { + return err + } + return invalidateUserCache(subUserId) +} + +// DeleteSubAccount 删除子账户(软删 user)。前置:名下不能有任何绑定记录。 +// 该校验在事务内进行,防止「校验通过→并发新增绑定→删除」的竞态留下悬空绑定。 +func DeleteSubAccount(subUserId, parentId int) error { + return DB.Transaction(func(tx *gorm.DB) error { + var sub User + if err := tx.Where("id = ? AND parent_user_id = ?", subUserId, parentId).First(&sub).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrSubAccountNotFound + } + return err + } + var bindingCount int64 + if err := tx.Model(&SubAccountTokenBinding{}).Where("sub_user_id = ?", subUserId).Count(&bindingCount).Error; err != nil { + return err + } + if bindingCount > 0 { + return ErrSubAccountHasBindings + } + if err := tx.Delete(&sub).Error; err != nil { + return err + } + return nil + }) +} + +// GetBoundTokenIdsBySubUser 返回某子账户已绑定的全部 token_id,用于数据范围过滤。 +func GetBoundTokenIdsBySubUser(subUserId int) ([]int, error) { + var ids []int + err := DB.Model(&SubAccountTokenBinding{}). + Where("sub_user_id = ?", subUserId). + Pluck("token_id", &ids).Error + return ids, err +} + +// GetBindingsBySubUser 返回某子账户的全部绑定记录(归属由调用方校验)。 +func GetBindingsBySubUser(subUserId int) ([]*SubAccountTokenBinding, error) { + var bindings []*SubAccountTokenBinding + err := DB.Where("sub_user_id = ?", subUserId).Order("id desc").Find(&bindings).Error + return bindings, err +} + +// GetBindingByTokenId 返回某 token 的绑定记录(不存在返回 nil, nil)。 +func GetBindingByTokenId(tokenId int) (*SubAccountTokenBinding, error) { + var binding SubAccountTokenBinding + err := DB.Where("token_id = ?", tokenId).First(&binding).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &binding, nil +} + +// GetBindingsByTokenIds 返回一批 token 中已被绑定的记录,用于批量删除保护与令牌列表「已绑定」徽标。 +func GetBindingsByTokenIds(tokenIds []int) ([]*SubAccountTokenBinding, error) { + if len(tokenIds) == 0 { + return nil, nil + } + var bindings []*SubAccountTokenBinding + err := DB.Where("token_id IN ?", tokenIds).Find(&bindings).Error + return bindings, err +} + +// BindTokenToSubAccount 绑定 key 到子账户。事务内强校验归属与唯一性: +// - 子账户必须隶属 parentId; +// - token 必须属于 parentId(防绑别家的 key,IDOR); +// - token 未被任何子账户绑定(uniqueIndex 兜底,事务内先查给出友好提示)。 +func BindTokenToSubAccount(parentId, subUserId, tokenId int) error { + return DB.Transaction(func(tx *gorm.DB) error { + var sub User + if err := tx.Where("id = ? AND parent_user_id = ?", subUserId, parentId).First(&sub).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrSubAccountNotFound + } + return err + } + var token Token + if err := tx.Where("id = ? AND user_id = ?", tokenId, parentId).First(&token).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("令牌不存在或不属于当前企业账户") + } + return err + } + var existing int64 + if err := tx.Model(&SubAccountTokenBinding{}).Where("token_id = ?", tokenId).Count(&existing).Error; err != nil { + return err + } + if existing > 0 { + return ErrTokenAlreadyBound + } + binding := &SubAccountTokenBinding{ + ParentUserId: parentId, + SubUserId: subUserId, + TokenId: tokenId, + } + return tx.Create(binding).Error + }) +} + +// UnbindTokenFromSubAccount 解绑。强校验:绑定记录必须属于 parentId 且 sub_user_id 匹配。 +func UnbindTokenFromSubAccount(parentId, subUserId, tokenId int) error { + result := DB.Where("parent_user_id = ? AND sub_user_id = ? AND token_id = ?", parentId, subUserId, tokenId). + Delete(&SubAccountTokenBinding{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrBindingNotFound + } + return nil +} + +// cascadeDeleteSubAccountsForParent 企业主账户被删除时跟随处理(设计 §4.3 异常路径): +// 软删其全部子账户、硬删该企业名下的全部绑定记录。运维级删号是逃生通道,不受绑定保护约束。 +// 在 user.Delete() 内调用;错误非致命(审计/关系数据),仅记录。 +func cascadeDeleteSubAccountsForParent(tx *gorm.DB, parentId int) { + // 硬删该企业名下全部绑定(含其各子账户的绑定)。 + if err := tx.Where("parent_user_id = ?", parentId).Delete(&SubAccountTokenBinding{}).Error; err != nil { + common.SysLog(fmt.Sprintf("cascade delete sub-account bindings for parent %d failed: %s", parentId, err.Error())) + } + // 软删全部子账户。 + if err := tx.Where("parent_user_id = ?", parentId).Delete(&User{}).Error; err != nil { + common.SysLog(fmt.Sprintf("cascade soft-delete sub-accounts for parent %d failed: %s", parentId, err.Error())) + } +} diff --git a/model/task.go b/model/task.go index 5d00de51339..45bacb572ae 100644 --- a/model/task.go +++ b/model/task.go @@ -10,6 +10,7 @@ import ( "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" commonRelay "github.com/QuantumNous/new-api/relay/common" + "gorm.io/gorm" ) type TaskStatus string @@ -50,6 +51,8 @@ type Task struct { UserId int `json:"user_id" gorm:"index"` Group string `json:"group" gorm:"type:varchar(50)"` // 修正计费用 ChannelId int `json:"channel_id" gorm:"index"` + // TokenId 由 BeforeSave 钩子从 PrivateData.TokenId 镜像而来,作为真列用于企业子账户「仅看绑定 key 的任务」过滤(设计 §4.5)。 + TokenId int `json:"token_id" gorm:"index;column:token_id"` Quota int `json:"quota"` Action string `json:"action" gorm:"type:varchar(40);index"` // 任务类型, song, lyrics, description-mode Status TaskStatus `json:"status" gorm:"type:varchar(20);index"` // 任务状态 @@ -65,6 +68,17 @@ type Task struct { Data json.RawMessage `json:"data" gorm:"type:json"` } +// BeforeSave 把 PrivateData.TokenId 镜像到真列 token_id,覆盖 Insert/Update/UpdateWithStatus 三条写路径。 +// token_id 仅在任务创建时(controller/relay.go)写入 PrivateData,后续轮询的 Update 携带的 PrivateData 不变,镜像幂等。 +func (t *Task) BeforeSave(tx *gorm.DB) error { + // 仅在 PrivateData.TokenId 有值时镜像;为空时不动 token_id,避免「部分结构体 + Select("*") 存盘」 + // 误把已有的非零 token_id 清零(评审护栏,现实路径都会加载完整 task,此处兜底未来新增写路径)。 + if t.PrivateData.TokenId != 0 { + t.TokenId = t.PrivateData.TokenId + } + return nil +} + func (t *Task) SetData(data any) { b, _ := common.Marshal(data) t.Data = json.RawMessage(b) @@ -167,6 +181,8 @@ type SyncTaskQueryParams struct { StartTimestamp int64 EndTimestamp int64 UserIDs []int + // TokenIds 非空时限定 token_id ∈ 集合,供企业子账户「仅看绑定 key 的任务」只读视图使用(设计 §4.5)。 + TokenIds []int } func InitTask(platform constant.TaskPlatform, relayInfo *commonRelay.RelayInfo) *Task { @@ -215,6 +231,9 @@ func TaskGetAllUserTask(userId int, startIdx int, num int, queryParams SyncTaskQ // 初始化查询构建器 query := DB.Where("user_id = ?", userId) + if len(queryParams.TokenIds) > 0 { + query = query.Where("token_id IN ?", queryParams.TokenIds) + } if queryParams.TaskID != "" { query = query.Where("task_id = ?", queryParams.TaskID) } @@ -485,6 +504,9 @@ func TaskCountAllTasks(queryParams SyncTaskQueryParams) int64 { func TaskCountAllUserTask(userId int, queryParams SyncTaskQueryParams) int64 { var total int64 query := DB.Model(&Task{}).Where("user_id = ?", userId) + if len(queryParams.TokenIds) > 0 { + query = query.Where("token_id IN ?", queryParams.TokenIds) + } if queryParams.TaskID != "" { query = query.Where("task_id = ?", queryParams.TaskID) } diff --git a/model/token.go b/model/token.go index ab841f6054e..9e60b5e8809 100644 --- a/model/token.go +++ b/model/token.go @@ -85,6 +85,17 @@ func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) { return tokens, err } +// GetTokensByIdsAndUser 按 id 集合取出归属 userId 的令牌,供企业子账户只读查看其绑定的 key。 +// 强制 user_id 过滤,避免越权取到别家令牌(IDOR)。 +func GetTokensByIdsAndUser(ids []int, userId int) ([]*Token, error) { + if len(ids) == 0 { + return []*Token{}, nil + } + var tokens []*Token + err := DB.Where("id IN ? AND user_id = ?", ids, userId).Order("id desc").Find(&tokens).Error + return tokens, err +} + // sanitizeLikePattern 校验并清洗用户输入的 LIKE 搜索模式。 // 规则: // 1. 转义 ! 和 _(使用 ! 作为 ESCAPE 字符,兼容 MySQL/PostgreSQL/SQLite) @@ -124,7 +135,9 @@ func sanitizeLikePattern(input string) (string, error) { const searchHardLimit = 100 -func SearchUserTokens(userId int, keyword string, token string, offset int, limit int) (tokens []*Token, total int64, err error) { +// SearchUserTokens 搜索某用户的令牌。tokenIds 非空时额外限定 id ∈ 集合, +// 供企业子账户在「绑定 key」范围内搜索(userId 传企业主 id);普通用户传 nil 即不过滤。 +func SearchUserTokens(userId int, keyword string, token string, offset int, limit int, tokenIds []int) (tokens []*Token, total int64, err error) { // model 层强制截断 if limit <= 0 || limit > searchHardLimit { limit = searchHardLimit @@ -152,6 +165,9 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi } baseQuery := DB.Model(&Token{}).Where("user_id = ?", userId) + if len(tokenIds) > 0 { + baseQuery = baseQuery.Where("id IN ?", tokenIds) + } // 非空才加 LIKE 条件,空则跳过(不过滤该字段) if keyword != "" { @@ -439,6 +455,21 @@ func CountUserTokens(userId int) (int64, error) { return total, err } +// IsTokenNameDuplicated 校验同一用户下令牌名称是否重复(便于企业子账户按名识别绑定令牌)。 +// 空名称不参与去重;excludeId>0 时排除该令牌自身(编辑场景)。软删令牌天然被 DeletedAt 过滤。 +func IsTokenNameDuplicated(userId int, name string, excludeId int) (bool, error) { + if strings.TrimSpace(name) == "" { + return false, nil + } + q := DB.Model(&Token{}).Where("user_id = ? AND name = ?", userId, name) + if excludeId > 0 { + q = q.Where("id <> ?", excludeId) + } + var total int64 + err := q.Count(&total).Error + return total > 0, err +} + // BatchDeleteTokens 删除指定用户的一组令牌,返回成功删除数量 func BatchDeleteTokens(ids []int, userId int) (int, error) { if len(ids) == 0 { diff --git a/model/topup.go b/model/topup.go index ac14b1a4069..185ad72bbf3 100644 --- a/model/topup.go +++ b/model/topup.go @@ -31,6 +31,7 @@ const ( PaymentMethodWaffoPancake = "waffo_pancake" PaymentMethodAlipay = "alipay" PaymentMethodWxpay = "wxpay" + PaymentMethodBankTransfer = "bank_transfer" ) const ( @@ -41,6 +42,7 @@ const ( PaymentProviderWaffoPancake = "waffo_pancake" PaymentProviderAlipayDirect = "alipay_direct" PaymentProviderWxpayDirect = "wxpay_direct" + PaymentProviderBankTransfer = "bank_transfer_manual" ) var ( diff --git a/model/usedata.go b/model/usedata.go index f0ea055ae39..538c29b17f7 100644 --- a/model/usedata.go +++ b/model/usedata.go @@ -115,6 +115,39 @@ func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData return quotaDatas, err } +// GetQuotaDataFromLogsByTokenIds 为企业子账户的数据看板从 logs 实时聚合(设计 §4.5,决策点 D10 同款理由)。 +// +// 不动 quota_data 聚合表(其无 token 维度,加列会让行数随 key 数线性膨胀并触碰上游热路径)。 +// 子账户是低频查看场景,从 logs 聚合(有 user_id/token_id 索引 + 时间窗限制)足够。返回结构与 +// GetQuotaDataByUserId 对齐,前端零改动。tokenIds 必须非空(空集合应由 controller 短路为空结果, +// 否则等价于不过滤会泄漏企业全量数据)。 +// +// 小时分桶用 created_at - created_at % 3600,% 在 MySQL/PostgreSQL/SQLite 通用, +// 与 LogQuotaData 落库时的分桶口径一致。 +func GetQuotaDataFromLogsByTokenIds(userId int, tokenIds []int, startTime int64, endTime int64) (quotaData []*QuotaData, err error) { + if len(tokenIds) == 0 { + return []*QuotaData{}, nil + } + hourExpr := "(logs.created_at - logs.created_at % 3600)" + var quotaDatas []*QuotaData + err = LOG_DB.Table("logs"). + Select("logs.model_name as model_name, "+hourExpr+" as created_at, "+ + "count(*) as count, sum(logs.quota) as quota, "+ + "sum(logs.prompt_tokens + logs.completion_tokens) as token_used"). + Where("logs.user_id = ? AND logs.token_id IN ? AND logs.type = ? AND logs.created_at >= ? AND logs.created_at <= ?", + userId, tokenIds, LogTypeConsume, startTime, endTime). + Group("logs.model_name, " + hourExpr). + Find("aDatas).Error + if err != nil { + return nil, err + } + // 补回 user_id(聚合未选该列),保持返回结构与 quota_data 行一致。 + for _, qd := range quotaDatas { + qd.UserID = userId + } + return quotaDatas, nil +} + func GetQuotaDataGroupByUser(startTime int64, endTime int64) (quotaData []*QuotaData, err error) { var quotaDatas []*QuotaData err = DB.Table("quota_data"). diff --git a/model/user.go b/model/user.go index ea5cc7f59db..ff33e3f9dd3 100644 --- a/model/user.go +++ b/model/user.go @@ -30,6 +30,8 @@ type User struct { Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled KycStatus int `json:"kyc_status" gorm:"type:int;default:0;column:kyc_status"` // 0=未认证 1=审核中 2=已通过 3=已拒绝 EnterpriseStatus int `json:"enterprise_status" gorm:"type:int;default:0;column:enterprise_status"` // 0=未认证 1=审核中 2=已通过 3=已拒绝 + ParentUserId int `json:"parent_user_id" gorm:"type:int;default:0;index;column:parent_user_id"` // >0 表示子账户,值为所属企业主账户 user_id;恒为只读视图,不参与计费 + ParentUsername string `json:"parent_username,omitempty" gorm:"-:all"` // 瞬态:子账户所属企业主账户的用户名,仅管理员列表按需填充展示归属,不入库 Email string `json:"email" gorm:"index" validate:"max=50"` GitHubId string `json:"github_id" gorm:"column:github_id;index"` DiscordId string `json:"discord_id" gorm:"column:discord_id;index"` @@ -38,9 +40,9 @@ type User struct { TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"` VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database! AccessToken *string `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management - Quota int `json:"quota" gorm:"type:int;default:0"` - UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota - RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number + Quota int `json:"quota" gorm:"type:bigint;default:0"` // bigint:32 位列上限仅 ~$4294(¥3.1万),对公转账大额入账必溢出(PG 报错/MySQL 截断) + UsedQuota int `json:"used_quota" gorm:"type:bigint;default:0;column:used_quota"` // used quota,同 Quota 升为 bigint + RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number Group string `json:"group" gorm:"type:varchar(64);default:'default'"` AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"` AffCount int `json:"aff_count" gorm:"type:int;default:0;column:aff_count"` @@ -68,6 +70,7 @@ func (user *User) ToBaseUser() *UserBase { Role: user.Role, KycStatus: user.KycStatus, EnterpriseStatus: user.EnterpriseStatus, + ParentUserId: user.ParentUserId, } return cache } @@ -195,6 +198,44 @@ func GetMaxUserId() int { return user.Id } +// FillParentUsernames 为子账户行(parent_user_id>0)批量填充其所属企业主账户的用户名, +// 供管理员用户列表展示归属关系。一次 IN 查询解决,避免逐行查导致 N+1。瞬态字段不入库。 +func FillParentUsernames(users []*User) { + parentIds := make([]int, 0) + seen := make(map[int]struct{}) + for _, u := range users { + if u != nil && u.ParentUserId > 0 { + if _, ok := seen[u.ParentUserId]; !ok { + seen[u.ParentUserId] = struct{}{} + parentIds = append(parentIds, u.ParentUserId) + } + } + } + if len(parentIds) == 0 { + return + } + type row struct { + Id int + Username string + } + var rows []row + // Unscoped:企业主账户可能已软删(但子账户尚未级联清理的边缘态),仍展示其名便于追溯。 + if err := DB.Unscoped().Model(&User{}).Select("id, username"). + Where("id IN ?", parentIds).Scan(&rows).Error; err != nil { + common.SysLog("failed to fill parent usernames: " + err.Error()) + return + } + nameMap := make(map[int]string, len(rows)) + for _, r := range rows { + nameMap[r.Id] = r.Username + } + for _, u := range users { + if u != nil && u.ParentUserId > 0 { + u.ParentUsername = nameMap[u.ParentUserId] + } + } +} + func GetAllUsers(pageInfo *common.PageInfo, kycStatus int, enterpriseStatus int) (users []*User, total int64, err error) { // Start transaction tx := DB.Begin() @@ -366,7 +407,16 @@ func HardDeleteUserById(id int) error { } DB.Unscoped().Where("user_id = ?", id).Delete(&UserEnterprise{}) - return DB.Unscoped().Delete(&User{}, "id = ?", id).Error + // 子账户跟随处理(与 User.Delete 的软删路径同语义):该用户若是企业主, + // 软删其全部子账户并清掉名下绑定;若其本身是子账户,清掉自己作为 + // sub_user 的绑定。绑定表无软删列,残留记录会因 token_id 唯一索引 + // 永久占用该令牌的绑定名额(无法再绑、删除保护拒删),必须随删号清理。 + // 级联清理与主体硬删包裹在单事务内:避免进程中途崩溃残留「绑定已删、主账户还在」的半状态。 + return DB.Transaction(func(tx *gorm.DB) error { + cascadeDeleteSubAccountsForParent(tx, id) + _ = tx.Where("sub_user_id = ?", id).Delete(&SubAccountTokenBinding{}).Error + return tx.Unscoped().Delete(&User{}, "id = ?", id).Error + }) } func inviteUser(inviterId int) (err error) { @@ -627,7 +677,14 @@ func (user *User) Delete() error { _ = SoftDeleteEnterpriseImagesByEnterpriseId(ent.Id) } _ = DB.Where("user_id = ?", user.Id).Delete(&UserEnterprise{}).Error - if err := DB.Delete(user).Error; err != nil { + // 企业主账户删除跟随处理:软删其全部子账户、硬删名下绑定记录(设计 §4.3 异常路径,逃生通道)。 + // 级联清理与主体软删包裹在单事务内:避免进程中途崩溃残留「绑定已删、主账户还在」的半状态。 + // 该用户若本身是子账户,一并硬删其作为 sub_user 的绑定,避免悬空绑定。 + if err := DB.Transaction(func(tx *gorm.DB) error { + cascadeDeleteSubAccountsForParent(tx, user.Id) + _ = tx.Where("sub_user_id = ?", user.Id).Delete(&SubAccountTokenBinding{}).Error + return tx.Delete(user).Error + }); err != nil { return err } diff --git a/model/user_cache.go b/model/user_cache.go index 037f36c1834..b937cdbc20c 100644 --- a/model/user_cache.go +++ b/model/user_cache.go @@ -25,6 +25,7 @@ type UserBase struct { Role int `json:"role"` KycStatus int `json:"kyc_status"` EnterpriseStatus int `json:"enterprise_status"` + ParentUserId int `json:"parent_user_id"` } func (user *UserBase) WriteContext(c *gin.Context) { @@ -37,6 +38,7 @@ func (user *UserBase) WriteContext(c *gin.Context) { common.SetContextKey(c, constant.ContextKeyUserRole, user.Role) common.SetContextKey(c, constant.ContextKeyUserKYCStatus, user.KycStatus) common.SetContextKey(c, constant.ContextKeyUserEnterpriseStatus, user.EnterpriseStatus) + common.SetContextKey(c, constant.ContextKeyUserParentId, user.ParentUserId) } func (user *UserBase) GetSetting() dto.UserSetting { @@ -122,6 +124,7 @@ func GetUserCache(userId int) (userCache *UserBase, err error) { Role: user.Role, KycStatus: user.KycStatus, EnterpriseStatus: user.EnterpriseStatus, + ParentUserId: user.ParentUserId, } return userCache, nil diff --git a/model/user_enterprise.go b/model/user_enterprise.go index 1658b5cc821..62506429066 100644 --- a/model/user_enterprise.go +++ b/model/user_enterprise.go @@ -55,7 +55,7 @@ type UserEnterprise struct { ContactName string `json:"contact_name,omitempty" gorm:"type:varchar(64)"` ContactPhone string `json:"contact_phone,omitempty" gorm:"type:varchar(32)"` SubmitCount int `json:"submit_count" gorm:"type:int;not null;default:0"` - Status int `json:"status" gorm:"type:int;not null;default:1"` + Status int `json:"status" gorm:"type:int;not null;default:1;index"` RejectReason string `json:"reject_reason,omitempty" gorm:"type:varchar(255)"` ReviewedBy int `json:"reviewed_by,omitempty" gorm:"type:int;column:reviewed_by"` SubmittedAt *time.Time `json:"submitted_at,omitempty"` @@ -396,3 +396,11 @@ func GetEnterpriseList(status int, keyword string, page, pageSize int) ([]*Enter } return rows, total, nil } + +// CountPendingEnterprise 待审核企业认证数(status=待审核)。 +func CountPendingEnterprise() (int64, error) { + var n int64 + err := DB.Model(&UserEnterprise{}). + Where("status = ?", EnterpriseStatusPending).Count(&n).Error + return n, err +} diff --git a/model/user_kyc.go b/model/user_kyc.go index 2f4750029fe..a0010fa7187 100644 --- a/model/user_kyc.go +++ b/model/user_kyc.go @@ -46,7 +46,7 @@ type UserKYC struct { IdNumberEnc string `json:"-" gorm:"type:text;column:id_number_enc;not null"` IdNumberHash string `json:"-" gorm:"type:varchar(64);column:id_number_hash;not null"` SubmitCount int `json:"submit_count" gorm:"type:int;not null;default:0"` - Status int `json:"status" gorm:"type:int;not null;default:1"` + Status int `json:"status" gorm:"type:int;not null;default:1;index"` RejectReason string `json:"reject_reason,omitempty" gorm:"type:varchar(255)"` ReviewedBy int `json:"reviewed_by,omitempty" gorm:"type:int;column:reviewed_by"` SubmittedAt *time.Time `json:"submitted_at,omitempty"` @@ -387,3 +387,11 @@ func GetKYCList(status int, keyword string, page, pageSize int) ([]*KYCAdminRow, } return rows, total, nil } + +// CountPendingKYC 待审核实名认证数(status=待审核)。 +func CountPendingKYC() (int64, error) { + var n int64 + err := DB.Model(&UserKYC{}). + Where("status = ?", KYCStatusPending).Count(&n).Error + return n, err +} diff --git a/router/api-router.go b/router/api-router.go index 6c6c562196f..28274cf3532 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -83,61 +83,86 @@ func SetApiRouter(router *gin.Engine) { selfRoute.GET("/self/groups", controller.GetUserGroups) selfRoute.GET("/self", controller.GetSelf) selfRoute.GET("/models", controller.GetUserModels) - selfRoute.PUT("/self", controller.UpdateSelf) - selfRoute.DELETE("/self", controller.DeleteSelf) + // 子账户凭据由企业主账户管理(M3-4):禁止自改用户名/密码/显示名。 + selfRoute.PUT("/self", middleware.SubAccountForbidden(), controller.UpdateSelf) + // 子账户由企业主账户管理生命周期,禁止自删(否则绕过「有绑定不可删」的绑定保护) + selfRoute.DELETE("/self", middleware.SubAccountForbidden(), controller.DeleteSelf) selfRoute.GET("/token", controller.GenerateAccessToken) selfRoute.GET("/passkey", controller.PasskeyStatus) - selfRoute.POST("/passkey/register/begin", controller.PasskeyRegisterBegin) - selfRoute.POST("/passkey/register/finish", controller.PasskeyRegisterFinish) + // 子账户不得自设 passkey(M3-4):注册新登录因子会让企业「改密码吊销」失效。 + selfRoute.POST("/passkey/register/begin", middleware.SubAccountForbidden(), controller.PasskeyRegisterBegin) + selfRoute.POST("/passkey/register/finish", middleware.SubAccountForbidden(), controller.PasskeyRegisterFinish) selfRoute.POST("/passkey/verify/begin", controller.PasskeyVerifyBegin) selfRoute.POST("/passkey/verify/finish", controller.PasskeyVerifyFinish) selfRoute.DELETE("/passkey", controller.PasskeyDelete) - selfRoute.GET("/aff", controller.GetAffCode) + // 子账户黑名单:一切能改变余额/产生消费承诺的入口全部封死(设计 §4.4)。 + // 子账户是企业主账户的只读视图,不能充值/兑换/邀请/认证/管理令牌。 + selfRoute.GET("/aff", middleware.SubAccountForbidden(), controller.GetAffCode) selfRoute.GET("/topup/info", controller.GetTopUpInfo) selfRoute.GET("/topup/self", controller.GetUserTopUps) - selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp) - selfRoute.POST("/pay", middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.RequestEpay) - selfRoute.POST("/amount", controller.RequestAmount) - selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.RequestStripePay) - selfRoute.POST("/stripe/amount", controller.RequestStripeAmount) - selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.RequestCreemPay) - selfRoute.POST("/waffo/amount", controller.RequestWaffoAmount) - selfRoute.POST("/waffo/pay", middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.RequestWaffoPay) + selfRoute.POST("/topup", middleware.SubAccountForbidden(), middleware.CriticalRateLimit(), controller.TopUp) + selfRoute.POST("/pay", middleware.SubAccountForbidden(), middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.RequestEpay) + selfRoute.POST("/amount", middleware.SubAccountForbidden(), controller.RequestAmount) + selfRoute.POST("/stripe/pay", middleware.SubAccountForbidden(), middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.RequestStripePay) + selfRoute.POST("/stripe/amount", middleware.SubAccountForbidden(), controller.RequestStripeAmount) + selfRoute.POST("/creem/pay", middleware.SubAccountForbidden(), middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.RequestCreemPay) + selfRoute.POST("/waffo/amount", middleware.SubAccountForbidden(), controller.RequestWaffoAmount) + selfRoute.POST("/waffo/pay", middleware.SubAccountForbidden(), middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.RequestWaffoPay) //selfRoute.POST("/waffo-pancake/amount", controller.RequestWaffoPancakeAmount) //selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.RequestWaffoPancakePay) - selfRoute.POST("/alipay/pay", middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.RequestAlipay) + selfRoute.POST("/alipay/pay", middleware.SubAccountForbidden(), middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.RequestAlipay) selfRoute.GET("/alipay/query", controller.QueryAlipayOrder) - selfRoute.POST("/wxpay/pay", middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.RequestWxpay) + selfRoute.POST("/wxpay/pay", middleware.SubAccountForbidden(), middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.RequestWxpay) selfRoute.GET("/wxpay/query", controller.QueryWxpayOrder) - selfRoute.POST("/aff_transfer", controller.TransferAffQuota) + selfRoute.POST("/aff_transfer", middleware.SubAccountForbidden(), controller.TransferAffQuota) selfRoute.PUT("/setting", controller.UpdateUserSetting) - // 2FA routes + // 2FA routes — 子账户不得自设 2FA(M3-4),同 passkey 理由;禁用/查询保留。 selfRoute.GET("/2fa/status", controller.Get2FAStatus) - selfRoute.POST("/2fa/setup", controller.Setup2FA) - selfRoute.POST("/2fa/enable", controller.Enable2FA) + selfRoute.POST("/2fa/setup", middleware.SubAccountForbidden(), controller.Setup2FA) + selfRoute.POST("/2fa/enable", middleware.SubAccountForbidden(), controller.Enable2FA) selfRoute.POST("/2fa/disable", controller.Disable2FA) - selfRoute.POST("/2fa/backup_codes", controller.RegenerateBackupCodes) + selfRoute.POST("/2fa/backup_codes", middleware.SubAccountForbidden(), controller.RegenerateBackupCodes) // Check-in routes selfRoute.GET("/checkin", controller.GetCheckinStatus) - selfRoute.POST("/checkin", middleware.TurnstileCheck(), controller.DoCheckin) + selfRoute.POST("/checkin", middleware.SubAccountForbidden(), middleware.TurnstileCheck(), controller.DoCheckin) // Custom OAuth bindings selfRoute.GET("/oauth/bindings", controller.GetUserOAuthBindings) selfRoute.DELETE("/oauth/bindings/:provider_id", controller.UnbindCustomOAuth) - // KYC routes + // KYC routes — 子账户不是独立法律主体,写操作封禁 selfRoute.GET("/kyc", controller.GetKYCStatus) - selfRoute.POST("/kyc", controller.SubmitKYC) - selfRoute.PUT("/kyc", controller.UpdateKYC) - selfRoute.DELETE("/kyc", controller.DeleteKYC) + selfRoute.POST("/kyc", middleware.SubAccountForbidden(), controller.SubmitKYC) + selfRoute.PUT("/kyc", middleware.SubAccountForbidden(), controller.UpdateKYC) + selfRoute.DELETE("/kyc", middleware.SubAccountForbidden(), controller.DeleteKYC) - // Enterprise certification routes + // Enterprise certification routes — 同上,写操作封禁 selfRoute.GET("/enterprise", controller.GetEnterpriseStatus) - selfRoute.POST("/enterprise", controller.SubmitEnterprise) - selfRoute.PUT("/enterprise", controller.UpdateEnterprise) - selfRoute.DELETE("/enterprise", controller.DeleteEnterprise) + selfRoute.POST("/enterprise", middleware.SubAccountForbidden(), controller.SubmitEnterprise) + selfRoute.PUT("/enterprise", middleware.SubAccountForbidden(), controller.UpdateEnterprise) + selfRoute.DELETE("/enterprise", middleware.SubAccountForbidden(), controller.DeleteEnterprise) + selfRoute.GET("/bank_transfer/config", controller.GetBankTransferConfig) + selfRoute.GET("/bank_transfer/self", controller.GetUserBankTransfers) + selfRoute.POST("/bank_transfer", middleware.SubAccountForbidden(), middleware.CriticalRateLimit(), controller.SubmitBankTransfer) + selfRoute.DELETE("/bank_transfer/:id", middleware.SubAccountForbidden(), controller.CancelBankTransfer) + selfRoute.GET("/invoice/quota", controller.GetInvoiceQuota) + selfRoute.GET("/invoice/self", controller.GetUserInvoices) + selfRoute.POST("/invoice", middleware.SubAccountForbidden(), middleware.CriticalRateLimit(), controller.SubmitInvoice) + selfRoute.DELETE("/invoice/:id", middleware.SubAccountForbidden(), controller.CancelInvoice) + selfRoute.GET("/invoice/:id/file", controller.GetUserInvoiceFile) + + // 子账户管理(企业主账户操作)。全部挂 SubAccountForbidden 防套娃; + // enterprise_status==2 前置校验在 controller 内进行。 + selfRoute.GET("/sub_account", middleware.SubAccountForbidden(), controller.GetSubAccounts) + selfRoute.POST("/sub_account", middleware.SubAccountForbidden(), controller.CreateSubAccount) + selfRoute.PUT("/sub_account/:id/password", middleware.SubAccountForbidden(), controller.ResetSubAccountPassword) + selfRoute.PUT("/sub_account/:id/status", middleware.SubAccountForbidden(), controller.SetSubAccountStatus) + selfRoute.DELETE("/sub_account/:id", middleware.SubAccountForbidden(), controller.DeleteSubAccount) + selfRoute.GET("/sub_account/:id/bindings", middleware.SubAccountForbidden(), controller.GetSubAccountBindings) + selfRoute.POST("/sub_account/:id/bind", middleware.SubAccountForbidden(), controller.BindSubAccountToken) + selfRoute.POST("/sub_account/:id/unbind", middleware.SubAccountForbidden(), controller.UnbindSubAccountToken) // Feedback (建议及咨询/工单) routes selfRoute.GET("/feedback/topics", controller.GetUserFeedbackTopics) @@ -175,7 +200,8 @@ func SetApiRouter(router *gin.Engine) { adminRoute.GET("/kyc/admin/by-user/:user_id", controller.AdminGetKYCByUser) adminRoute.PUT("/kyc/admin/:id/approve", controller.AdminApproveKYC) adminRoute.PUT("/kyc/admin/:id/reject", controller.AdminRejectKYC) - adminRoute.PUT("/kyc/admin/:id/reset", controller.AdminResetKYC) + // 重置仅超管:清空认证状态影响较大,叠加 RootAuth 强制(前端按钮也仅 root 可见) + adminRoute.PUT("/kyc/admin/:id/reset", middleware.RootAuth(), controller.AdminResetKYC) adminRoute.GET("/kyc/admin/:id/reveal", controller.AdminRevealKYC) adminRoute.GET("/kyc/admin/:id/images", controller.AdminGetKYCImages) @@ -184,9 +210,21 @@ func SetApiRouter(router *gin.Engine) { adminRoute.GET("/enterprise/admin/by-user/:user_id", controller.AdminGetEnterpriseByUser) adminRoute.PUT("/enterprise/admin/:id/approve", controller.AdminApproveEnterprise) adminRoute.PUT("/enterprise/admin/:id/reject", controller.AdminRejectEnterprise) - adminRoute.PUT("/enterprise/admin/:id/reset", controller.AdminResetEnterprise) + // 重置仅超管(同 KYC) + adminRoute.PUT("/enterprise/admin/:id/reset", middleware.RootAuth(), controller.AdminResetEnterprise) adminRoute.GET("/enterprise/admin/:id/reveal", controller.AdminRevealEnterprise) adminRoute.GET("/enterprise/admin/:id/images", controller.AdminGetEnterpriseImages) + adminRoute.GET("/bank_transfer/admin", controller.AdminGetBankTransferList) + adminRoute.GET("/bank_transfer/admin/:id/receipt", controller.AdminGetBankTransferReceipt) + adminRoute.PUT("/bank_transfer/admin/:id/approve", controller.AdminApproveBankTransfer) + adminRoute.PUT("/bank_transfer/admin/:id/reject", controller.AdminRejectBankTransfer) + adminRoute.GET("/invoice/admin", controller.AdminGetInvoiceList) + adminRoute.PUT("/invoice/admin/:id/issue", controller.AdminIssueInvoice) + adminRoute.PUT("/invoice/admin/:id/reject", controller.AdminRejectInvoice) + adminRoute.GET("/invoice/admin/:id/file", controller.AdminGetInvoiceFile) + + // 审核待办计数(侧边栏/页签红点):实名认证 / 企业认证 / 对公转账+发票 + adminRoute.GET("/review/pending_counts", controller.GetReviewPendingCounts) // Feedback admin routes — static segments before /:id adminRoute.GET("/feedback/admin/topics", controller.AdminGetFeedbackTopics) @@ -204,13 +242,13 @@ func SetApiRouter(router *gin.Engine) { { subscriptionRoute.GET("/plans", controller.GetSubscriptionPlans) subscriptionRoute.GET("/self", controller.GetSubscriptionSelf) - subscriptionRoute.PUT("/self/preference", controller.UpdateSubscriptionPreference) - subscriptionRoute.POST("/epay/pay", middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.SubscriptionRequestEpay) - subscriptionRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.SubscriptionRequestStripePay) - subscriptionRoute.POST("/creem/pay", middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.SubscriptionRequestCreemPay) - subscriptionRoute.POST("/alipay/pay", middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.SubscriptionRequestAlipay) + subscriptionRoute.PUT("/self/preference", middleware.SubAccountForbidden(), controller.UpdateSubscriptionPreference) + subscriptionRoute.POST("/epay/pay", middleware.SubAccountForbidden(), middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.SubscriptionRequestEpay) + subscriptionRoute.POST("/stripe/pay", middleware.SubAccountForbidden(), middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.SubscriptionRequestStripePay) + subscriptionRoute.POST("/creem/pay", middleware.SubAccountForbidden(), middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.SubscriptionRequestCreemPay) + subscriptionRoute.POST("/alipay/pay", middleware.SubAccountForbidden(), middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.SubscriptionRequestAlipay) subscriptionRoute.GET("/alipay/query", controller.SubscriptionQueryAlipayOrder) - subscriptionRoute.POST("/wxpay/pay", middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.SubscriptionRequestWxpay) + subscriptionRoute.POST("/wxpay/pay", middleware.SubAccountForbidden(), middleware.CriticalRateLimit(), middleware.KYCRequired(), controller.SubscriptionRequestWxpay) subscriptionRoute.GET("/wxpay/query", controller.SubscriptionQueryWxpayOrder) } subscriptionAdminRoute := apiRouter.Group("/subscription/admin") @@ -340,10 +378,11 @@ func SetApiRouter(router *gin.Engine) { tokenRoute.GET("/search", middleware.SearchRateLimit(), controller.SearchTokens) tokenRoute.GET("/:id", controller.GetToken) tokenRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKey) - tokenRoute.POST("/", controller.AddToken) - tokenRoute.PUT("/", controller.UpdateToken) - tokenRoute.DELETE("/:id", controller.DeleteToken) - tokenRoute.POST("/batch", controller.DeleteTokenBatch) + // 子账户令牌页只读:禁止创建/修改/删除(含批量);读取走 GetAllTokens 的子账户分支。 + tokenRoute.POST("/", middleware.SubAccountForbidden(), controller.AddToken) + tokenRoute.PUT("/", middleware.SubAccountForbidden(), controller.UpdateToken) + tokenRoute.DELETE("/:id", middleware.SubAccountForbidden(), controller.DeleteToken) + tokenRoute.POST("/batch", middleware.SubAccountForbidden(), controller.DeleteTokenBatch) tokenRoute.POST("/batch/keys", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKeysBatch) } @@ -405,7 +444,8 @@ func SetApiRouter(router *gin.Engine) { } mjRoute := apiRouter.Group("/mj") - mjRoute.GET("/self", middleware.UserAuth(), controller.GetUserMidjourney) + // 绘图日志本期不对子账户开放(D10):midjourneys 无 token_id 维度,无法按绑定集合过滤。 + mjRoute.GET("/self", middleware.UserAuth(), middleware.SubAccountForbidden(), controller.GetUserMidjourney) mjRoute.GET("/", middleware.AdminAuth(), controller.GetAllMidjourney) taskRoute := apiRouter.Group("/task") diff --git a/setting/operation_setting/bank_transfer_setting.go b/setting/operation_setting/bank_transfer_setting.go new file mode 100644 index 00000000000..ed3463cddc2 --- /dev/null +++ b/setting/operation_setting/bank_transfer_setting.go @@ -0,0 +1,30 @@ +package operation_setting + +import "github.com/QuantumNous/new-api/setting/config" + +// BankTransferSetting 对公转账收款配置(docs/enterprise-features-design.md §2.2)。 +// 收款四要素是公开信息(用户必须看到才能转账),明文存储、随用户侧接口下发。 +type BankTransferSetting struct { + Enabled bool `json:"enabled"` + CompanyName string `json:"company_name"` // 公司名称 + PayeeName string `json:"payee_name"` // 收款单位 + AccountNumber string `json:"account_number"` // 收款账号 + BankName string `json:"bank_name"` // 开户行 + MinAmountFen int64 `json:"min_amount_fen"` // 最低单笔转账金额(分),0=不限 + Tips string `json:"tips"` // 卡片附加说明(如"转账请备注注册邮箱") +} + +var bankTransferSetting = BankTransferSetting{} + +func init() { + config.GlobalConfig.Register("bank_transfer_setting", &bankTransferSetting) +} + +func GetBankTransferSetting() *BankTransferSetting { + return &bankTransferSetting +} + +// IsAvailable 启用且收款四要素齐备时对公转账才可用。 +func (s *BankTransferSetting) IsAvailable() bool { + return s.Enabled && s.CompanyName != "" && s.PayeeName != "" && s.AccountNumber != "" && s.BankName != "" +} diff --git a/setting/operation_setting/sub_account_setting.go b/setting/operation_setting/sub_account_setting.go new file mode 100644 index 00000000000..b51e1a01b6c --- /dev/null +++ b/setting/operation_setting/sub_account_setting.go @@ -0,0 +1,25 @@ +package operation_setting + +import "github.com/QuantumNous/new-api/setting/config" + +// SubAccountSetting 企业子账户配置(docs/enterprise-features-design.md 功能C,决策点 D7)。 +type SubAccountSetting struct { + MaxCount int `json:"max_count"` // 单个企业账户可创建的子账户数量上限 +} + +// 默认配置:每个企业账户最多 10 个子账户(D7)。 +var subAccountSetting = SubAccountSetting{ + MaxCount: 10, +} + +func init() { + config.GlobalConfig.Register("sub_account_setting", &subAccountSetting) +} + +// GetSubAccountMaxCount 返回子账户数量上限;非法配置回退到默认 10,避免上限被误设为 0 后无法创建。 +func GetSubAccountMaxCount() int { + if subAccountSetting.MaxCount <= 0 { + return 10 + } + return subAccountSetting.MaxCount +} diff --git a/web/classic/src/App.jsx b/web/classic/src/App.jsx index b6f2d09ce5f..e5bddbb6352 100644 --- a/web/classic/src/App.jsx +++ b/web/classic/src/App.jsx @@ -50,8 +50,10 @@ import OAuth2Callback from './components/auth/OAuth2Callback'; import PersonalSetting from './components/settings/PersonalSetting'; import KYCPage from './pages/KYC'; import EnterprisePage from './pages/Enterprise'; +import BankTransferPage from './pages/BankTransfer'; import FeedbackPage from './pages/Feedback'; import MyFeedbackPage from './pages/Feedback/MyFeedback'; +import SubAccountPage from './pages/SubAccount'; import Setup from './pages/Setup'; import SetupCheck from './components/layout/SetupCheck'; @@ -200,6 +202,14 @@ function App() { } /> + + + + } + /> } /> + + + + } + /> . For commercial licensing, please contact support@quantumnous.com */ -import React from 'react'; +import React, { useContext } from 'react'; import { Card, Avatar, Skeleton, Tag } from '@douyinfe/semi-ui'; import { VChart } from '@visactor/react-vchart'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { UserContext } from '../../context/User'; const StatsCards = ({ groupedStatsData, @@ -32,6 +33,9 @@ const StatsCards = ({ }) => { const navigate = useNavigate(); const { t } = useTranslation(); + // 子账户不能充值,看板余额卡片隐藏「充值」按钮(后端充值接口已 403 兜底) + const [userState] = useContext(UserContext); + const isSubAccount = (userState?.user?.parent_user_id || 0) > 0; return (
@@ -80,7 +84,7 @@ const StatsCards = ({
- {item.title === t('当前余额') ? ( + {item.title === t('当前余额') && !isSubAccount ? ( { '/console/user', '/console/kyc', '/console/enterprise', + '/console/bank-transfer', '/console/token', '/console/midjourney', '/console/task', @@ -83,6 +84,20 @@ const PageLayout = () => { if (user) { let data = JSON.parse(user); userDispatch({ type: 'login', payload: data }); + // 后台拉一次 /self 刷新登录态快照:localStorage 里的登录响应可能缺 + // parent_user_id / enterprise_status 等字段(旧版本登录的存量用户), + // 或管理员中途变更了认证/子账户状态。子账户的侧边栏强制覆盖、 + // 企业主的「子账户管理」入口都依赖这些字段即时生效。 + API.get('/api/user/self') + .then((res) => { + if (res.data?.success && res.data.data?.id) { + userDispatch({ type: 'login', payload: res.data.data }); + localStorage.setItem('user', JSON.stringify(res.data.data)); + } + }) + .catch(() => { + // 401 由全局拦截器处理(清登录态并跳转);其余失败保持本地快照 + }); } }; diff --git a/web/classic/src/components/layout/SiderBar.jsx b/web/classic/src/components/layout/SiderBar.jsx index cfccc7b742b..4afbcae2eae 100644 --- a/web/classic/src/components/layout/SiderBar.jsx +++ b/web/classic/src/components/layout/SiderBar.jsx @@ -17,15 +17,17 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useContext, useEffect, useMemo, useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { UserContext } from '../../context/User'; import { getLucideIcon } from '../../helpers/render'; import { ChevronLeft } from 'lucide-react'; import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed'; import { useSidebar } from '../../hooks/common/useSidebar'; import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime'; import { useFeedbackUnread } from '../../hooks/common/useFeedbackUnread'; +import { useReviewPendingCounts } from '../../hooks/common/useReviewPendingCounts'; import { isAdmin, isRoot, showError } from '../../helpers'; import SkeletonWrapper from './components/SkeletonWrapper'; @@ -53,8 +55,10 @@ const routerMap = { personal: '/console/personal', kyc: '/console/kyc', enterprise: '/console/enterprise', + bankTransfer: '/console/bank-transfer', feedback: '/console/feedback', myfeedback: '/console/myfeedback', + subAccounts: '/console/sub-accounts', }; const SiderBar = ({ onNavigate = () => {} }) => { @@ -68,6 +72,15 @@ const SiderBar = ({ onNavigate = () => {} }) => { const showSkeleton = useMinimumLoadingTime(sidebarLoading, 200); const { userUnread, adminUnread } = useFeedbackUnread(); + const reviewCounts = useReviewPendingCounts(); + + // 子账户视图强制覆盖(设计 §4.6):不依赖用户自己的 Setting JSON,按 parent_user_id 渲染层硬覆盖。 + // - 子账户(parent_user_id>0):隐藏钱包/绘图日志/操练场/聊天,只保留看板·令牌(只读)·日志·任务·个人设置; + // - 企业主账户(enterprise_status===2 且非子账户):额外显示「子账户管理」入口。 + const [userState] = useContext(UserContext); + const isSubAccount = (userState?.user?.parent_user_id || 0) > 0; + const isEnterpriseOwner = + userState?.user?.enterprise_status === 2 && !isSubAccount; // 给菜单文案附一个未读计数小红标 const withUnreadBadge = (label, count) => { @@ -142,6 +155,8 @@ const SiderBar = ({ onNavigate = () => {} }) => { // 根据配置过滤项目 const filteredItems = items.filter((item) => { const configVisible = isModuleVisible('console', item.itemKey); + // 子账户:绘图日志本期不开放(D10),强制隐藏 + if (isSubAccount && item.itemKey === 'midjourney') return false; return configVisible; }); @@ -152,6 +167,7 @@ const SiderBar = ({ onNavigate = () => {} }) => { localStorage.getItem('enable_task'), t, isModuleVisible, + isSubAccount, ]); const financeItems = useMemo(() => { @@ -176,11 +192,25 @@ const SiderBar = ({ onNavigate = () => {} }) => { // 根据配置过滤项目 const filteredItems = items.filter((item) => { const configVisible = isModuleVisible('personal', item.itemKey); + // 子账户强制隐藏:钱包管理(不能充值)、个人设置(配置项过多,凭证由企业管理、 + // 密码走企业重置)。只读子账户的个人中心仅保留剩余必要入口。 + if (isSubAccount && (item.itemKey === 'topup' || item.itemKey === 'personal')) { + return false; + } return configVisible; }); + // 企业主账户额外显示「子账户管理」入口(绕过 isModuleVisible,配置中无此模块)。 + if (isEnterpriseOwner) { + filteredItems.push({ + text: t('子账户管理'), + itemKey: 'subAccounts', + to: '/sub-accounts', + }); + } + return filteredItems; - }, [t, isModuleVisible, userUnread]); + }, [t, isModuleVisible, userUnread, isSubAccount, isEnterpriseOwner]); const adminItems = useMemo(() => { const items = [ @@ -233,17 +263,23 @@ const SiderBar = ({ onNavigate = () => {} }) => { className: isAdmin() ? '' : 'tableHiddle', }, { - text: t('实名认证'), + text: withUnreadBadge(t('实名认证'), reviewCounts.kyc), itemKey: 'kyc', to: '/kyc', className: isAdmin() ? '' : 'tableHiddle', }, { - text: t('企业认证'), + text: withUnreadBadge(t('企业认证'), reviewCounts.enterprise), itemKey: 'enterprise', to: '/enterprise', className: isAdmin() ? '' : 'tableHiddle', }, + { + text: withUnreadBadge(t('对公转账'), reviewCounts.bank_transfer_total), + itemKey: 'bankTransfer', + to: '/bank-transfer', + className: isAdmin() ? '' : 'tableHiddle', + }, { text: t('系统设置'), itemKey: 'setting', @@ -259,7 +295,7 @@ const SiderBar = ({ onNavigate = () => {} }) => { }); return filteredItems; - }, [isAdmin(), isRoot(), t, isModuleVisible, adminUnread]); + }, [isAdmin(), isRoot(), t, isModuleVisible, adminUnread, reviewCounts]); const chatMenuItems = useMemo(() => { const items = [ @@ -275,6 +311,11 @@ const SiderBar = ({ onNavigate = () => {} }) => { }, ]; + // 子账户:操练场/聊天强制隐藏(D9,子账户 quota=0 天然无法消费,前端同时隐藏入口) + if (isSubAccount) { + return []; + } + // 根据配置过滤项目 const filteredItems = items.filter((item) => { const configVisible = isModuleVisible('chat', item.itemKey); @@ -282,7 +323,7 @@ const SiderBar = ({ onNavigate = () => {} }) => { }); return filteredItems; - }, [chatItems, t, isModuleVisible]); + }, [chatItems, t, isModuleVisible, isSubAccount]); // 更新路由映射,添加聊天路由 const updateRouterMapWithChats = (chats) => { @@ -504,8 +545,8 @@ const SiderBar = ({ onNavigate = () => {} }) => { setOpenedKeys(data.openKeys); }} > - {/* 聊天区域 */} - {hasSectionVisibleModules('chat') && ( + {/* 聊天区域 —— 子账户整组隐藏(含「聊天」分组标签,菜单项已在 chatMenuItems 清空) */} + {!isSubAccount && hasSectionVisibleModules('chat') && (
{!collapsed && (
{t('聊天')}
diff --git a/web/classic/src/components/layout/headerbar/UserArea.jsx b/web/classic/src/components/layout/headerbar/UserArea.jsx index 9fc011da18a..17439cfc861 100644 --- a/web/classic/src/components/layout/headerbar/UserArea.jsx +++ b/web/classic/src/components/layout/headerbar/UserArea.jsx @@ -59,20 +59,23 @@ const UserArea = ({ getPopupContainer={() => dropdownRef.current} render={ - { - navigate('/console/personal'); - }} - className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white' - > -
- - {t('个人设置')} -
-
+ {/* 子账户隐藏个人设置入口(配置项过多,凭证由企业管理) */} + {!((userState?.user?.parent_user_id || 0) > 0) && ( + { + navigate('/console/personal'); + }} + className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white' + > +
+ + {t('个人设置')} +
+
+ )} { navigate('/console/token'); @@ -87,20 +90,23 @@ const UserArea = ({ {t('令牌管理')}
- { - navigate('/console/topup'); - }} - className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white' - > -
- - {t('钱包管理')} -
-
+ {/* 子账户不能充值,隐藏钱包入口(与侧边栏覆盖一致;后端充值接口已 403 兜底) */} + {!((userState?.user?.parent_user_id || 0) > 0) && ( + { + navigate('/console/topup'); + }} + className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white' + > +
+ + {t('钱包管理')} +
+
+ )} { WxpayPublicKey: '', WxpayPublicKeyId: '', WxpayMinTopUp: 1, + + BankTransferEnabled: false, + BankTransferCompanyName: '', + BankTransferPayeeName: '', + BankTransferAccountNumber: '', + BankTransferBankName: '', + BankTransferMinAmountFen: 0, + BankTransferTips: '', }); let [loading, setLoading] = useState(false); @@ -143,6 +152,28 @@ const PaymentSetting = () => { case 'AlipaySandbox': newInputs[item.key] = toBoolean(item.value); break; + case 'bank_transfer_setting.enabled': + newInputs['BankTransferEnabled'] = toBoolean(item.value); + break; + case 'bank_transfer_setting.company_name': + newInputs['BankTransferCompanyName'] = item.value; + break; + case 'bank_transfer_setting.payee_name': + newInputs['BankTransferPayeeName'] = item.value; + break; + case 'bank_transfer_setting.account_number': + newInputs['BankTransferAccountNumber'] = item.value; + break; + case 'bank_transfer_setting.bank_name': + newInputs['BankTransferBankName'] = item.value; + break; + case 'bank_transfer_setting.min_amount_fen': + newInputs['BankTransferMinAmountFen'] = + parseInt(item.value, 10) || 0; + break; + case 'bank_transfer_setting.tips': + newInputs['BankTransferTips'] = item.value; + break; default: if (item.key.endsWith('Enabled')) { newInputs[item.key] = toBoolean(item.value); @@ -232,6 +263,13 @@ const PaymentSetting = () => { hideSectionTitle /> + + + {/**/} {/* { onPasskeyDelete={handleRemovePasskey} /> - {/* 实名认证 */} -
- -
+ {/* 实名认证 —— 子账户不是独立法律主体,隐藏认证卡片(设计 §4.6) */} + {(userState?.user?.parent_user_id || 0) === 0 && ( +
+ +
+ )} {/* 右侧:通知设置 + 企业认证 */} @@ -634,7 +636,10 @@ const PersonalSetting = () => { } saveNotificationSettings={saveNotificationSettings} /> - + {/* 企业认证 —— 子账户隐藏 */} + {(userState?.user?.parent_user_id || 0) === 0 && ( + + )} diff --git a/web/classic/src/components/settings/personal/cards/KYCSetting.jsx b/web/classic/src/components/settings/personal/cards/KYCSetting.jsx index eaf2164d211..93dbdf97e8c 100644 --- a/web/classic/src/components/settings/personal/cards/KYCSetting.jsx +++ b/web/classic/src/components/settings/personal/cards/KYCSetting.jsx @@ -39,7 +39,11 @@ function validate(form, t) { // Compress an image File to a base64 string (no data-URI prefix). // Scales down to maxLongEdgePx, encodes as JPEG, retries with lower quality // if the result exceeds maxSizeKB. -async function compressImageToBase64(file, maxLongEdgePx = 2400, maxSizeKB = 1500) { +async function compressImageToBase64( + file, + maxLongEdgePx = 2400, + maxSizeKB = 1500, +) { return new Promise((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(file); @@ -62,16 +66,22 @@ async function compressImageToBase64(file, maxLongEdgePx = 2400, maxSizeKB = 150 const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, width, height); - const tryEncode = (quality) => { + // Encode at the given quality. Retry exactly once at a lower quality if + // the result is still over target — a single fallback, never a loop + // (re-encoding at a fixed quality would never shrink and would hang). + const tryEncode = (quality, isRetry) => { canvas.toBlob( (blob) => { - if (!blob) { reject(new Error('canvas.toBlob failed')); return; } + if (!blob) { + reject(new Error('canvas.toBlob failed')); + return; + } const reader = new FileReader(); reader.onload = () => { // Strip the "data:image/jpeg;base64," prefix const b64 = reader.result.split(',')[1]; - if (b64.length > maxSizeKB * 1024 * (4 / 3) && quality > 0.6) { - tryEncode(0.82); + if (!isRetry && b64.length > maxSizeKB * 1024 * (4 / 3)) { + tryEncode(0.82, true); } else { resolve(b64); } @@ -83,7 +93,7 @@ async function compressImageToBase64(file, maxLongEdgePx = 2400, maxSizeKB = 150 quality, ); }; - tryEncode(0.88); + tryEncode(0.88, false); }; img.onerror = reject; img.src = url; @@ -142,25 +152,46 @@ function ImageSlot({ label, base64, onSelect, onClear }) { alt={label} style={{ maxWidth: '100%', maxHeight: 140, borderRadius: 4 }} /> -
+
) : ( <> - - {label} - {t('点击上传')} + + + {label} + + + {t('点击上传')} + )}
@@ -304,7 +335,11 @@ export default function KYCSetting() {
{t('您尚未提交实名认证信息')}
-
@@ -312,11 +347,17 @@ export default function KYCSetting() { {status === 1 && (
- {t('姓名')}:{kyc.real_name} + + {t('姓名')}:{kyc.real_name} +
- {t('证件类型')}:{t('居民身份证')} + + {t('证件类型')}:{t('居民身份证')} +
- {t('证件号')}:{kyc.id_number_masked} + + {t('证件号')}:{kyc.id_number_masked} +
{t('已提交,等待管理员审核')} @@ -333,11 +374,17 @@ export default function KYCSetting() { {status === 2 && (
- {t('姓名')}:{kyc.real_name} + + {t('姓名')}:{kyc.real_name} +
- {t('证件类型')}:{t('居民身份证')} + + {t('证件类型')}:{t('居民身份证')} +
- {t('证件号')}:{kyc.id_number_masked} + + {t('证件号')}:{kyc.id_number_masked} +
{kyc.verified_at && ( @@ -386,7 +433,9 @@ export default function KYCSetting() {
{/* 真实姓名 */}
-