cyi-box: Memphis-style toolbox admin platform
Go(fun)+SQLite backend, Vue 3 frontend. Modules: dashboard, pay gateway (Epay-compatible + CDK stock), license/card system (per-app appid, domain binding), AI memory bank with built-in MCP server (/mcp, FTS5 search, signed read links), server SSH monitor + terminal + SFTP, proxy pool with protocol-level alive checks, DB manager (mysql/pg/sqlite + SQL autocomplete + import/export), disk share, vault (AES-GCM + TOTP), bookmarks, dev tools. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/dto"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
)
|
||||
|
||||
// 登录态:24h 滑动过期;距上次续期超过 2h 的请求自动把有效期顺延回 24h
|
||||
const sessionTTL = 24 * time.Hour
|
||||
const sessionRenewAfter = 2 * time.Hour
|
||||
|
||||
type AuthSvc struct {
|
||||
fun.Ctx
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
// Login 用户名+密码 → 签发会话 token(前端存 localStorage,随 state 透传)
|
||||
func (s *AuthSvc) Login(d dto.LoginDto) (dto.SessionView, error) {
|
||||
name := strings.TrimSpace(d.Name)
|
||||
if name == "" || d.Password == "" {
|
||||
return dto.SessionView{}, fun.Error(4001, "请输入用户名和密码")
|
||||
}
|
||||
user, err := s.Store.GetUserByName(name)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return dto.SessionView{}, fun.Error(4001, "用户名或密码错误")
|
||||
}
|
||||
if err != nil {
|
||||
return dto.SessionView{}, fmt.Errorf("auth: query user failed: %w", err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(d.Password)) != nil {
|
||||
return dto.SessionView{}, fun.Error(4001, "用户名或密码错误")
|
||||
}
|
||||
if user.Status != "active" {
|
||||
return dto.SessionView{}, fun.Error(4003, "账号已被禁用")
|
||||
}
|
||||
token, err := newToken()
|
||||
if err != nil {
|
||||
return dto.SessionView{}, err
|
||||
}
|
||||
if err := s.Store.CreateSession(token, user.Id, sessionTTL); err != nil {
|
||||
return dto.SessionView{}, fmt.Errorf("auth: create session failed: %w", err)
|
||||
}
|
||||
log.Printf("auth: user %q logged in from %s", user.Name, s.Ip)
|
||||
view := userView(user)
|
||||
return dto.SessionView{User: &view, Token: &token}, nil
|
||||
}
|
||||
|
||||
// Me 返回当前登录用户(不重复签发 token)
|
||||
func (s *AuthSvc) Me() (dto.SessionView, error) {
|
||||
su, err := sessionFrom(s.RequestCtx)
|
||||
if err != nil {
|
||||
return dto.SessionView{}, err
|
||||
}
|
||||
view := userView(su.User)
|
||||
return dto.SessionView{User: &view}, nil
|
||||
}
|
||||
|
||||
// Logout 幂等:删掉当前会话即可
|
||||
func (s *AuthSvc) Logout() (dto.OkView, error) {
|
||||
su, err := sessionFrom(s.RequestCtx)
|
||||
if err != nil {
|
||||
return dto.OkView{Message: "已退出"}, nil
|
||||
}
|
||||
if err := s.Store.DeleteSession(su.Token); err != nil {
|
||||
return dto.OkView{}, fmt.Errorf("auth: logout failed: %w", err)
|
||||
}
|
||||
return dto.OkView{Message: "已退出"}, nil
|
||||
}
|
||||
|
||||
// ChangePassword 校验旧密码 → 改密 → 吊销其他会话(当前会话保留)
|
||||
func (s *AuthSvc) ChangePassword(d dto.ChangePasswordDto) (dto.OkView, error) {
|
||||
su, err := sessionFrom(s.RequestCtx)
|
||||
if err != nil {
|
||||
return dto.OkView{}, err
|
||||
}
|
||||
if len(d.NewPassword) < 6 {
|
||||
return dto.OkView{}, fun.Error(4001, "新密码至少 6 位")
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(su.User.PasswordHash), []byte(d.OldPassword)) != nil {
|
||||
return dto.OkView{}, fun.Error(4001, "旧密码错误")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(d.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return dto.OkView{}, err
|
||||
}
|
||||
h := string(hash)
|
||||
if err := s.Store.UpdateUser(su.User.Id, su.User.Name, su.User.Role, su.User.Status, &h); err != nil {
|
||||
return dto.OkView{}, fmt.Errorf("auth: update password failed: %w", err)
|
||||
}
|
||||
if err := s.Store.DeleteOtherSessions(su.User.Id, su.Token); err != nil {
|
||||
return dto.OkView{}, fmt.Errorf("auth: revoke sessions failed: %w", err)
|
||||
}
|
||||
return dto.OkView{Message: "密码已修改"}, nil
|
||||
}
|
||||
|
||||
func newToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func userView(u store.User) dto.UserView {
|
||||
return dto.UserView{
|
||||
Id: u.Id,
|
||||
Name: u.Name,
|
||||
Role: u.Role,
|
||||
Status: u.Status,
|
||||
CreatedAt: u.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
"github.com/valyala/fasthttp"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
)
|
||||
|
||||
// 认证统一业务错误码:前端响应拦截器据此清理登录态并跳登录页
|
||||
const authErrCode = 4011
|
||||
|
||||
type authPolicy uint8
|
||||
|
||||
const (
|
||||
policyDeny authPolicy = iota
|
||||
policyPublic
|
||||
policyAuthenticated
|
||||
policyAdmin
|
||||
)
|
||||
|
||||
// endpointPolicies 显式端点策略表:缺省拒绝,新端点必须登记
|
||||
var endpointPolicies = map[string]authPolicy{
|
||||
"AuthSvc.Login": policyPublic,
|
||||
"AuthSvc.Me": policyAuthenticated,
|
||||
"AuthSvc.Logout": policyAuthenticated,
|
||||
"AuthSvc.ChangePassword": policyAuthenticated,
|
||||
"UserSvc.ListUsers": policyAdmin,
|
||||
"UserSvc.SaveUser": policyAdmin,
|
||||
"UserSvc.DeleteUser": policyAdmin,
|
||||
"DashboardSvc.Stats": policyAuthenticated,
|
||||
"ToolSvc.List": policyAuthenticated,
|
||||
"ToolSvc.AdminList": policyAdmin,
|
||||
"ToolSvc.SaveTool": policyAdmin,
|
||||
"ToolSvc.DeleteTool": policyAdmin,
|
||||
"VaultSvc.List": policyAuthenticated,
|
||||
"VaultSvc.Save": policyAuthenticated,
|
||||
"VaultSvc.Reveal": policyAuthenticated,
|
||||
"VaultSvc.Totp": policyAuthenticated,
|
||||
"VaultSvc.Delete": policyAuthenticated,
|
||||
"BookmarkSvc.List": policyAuthenticated,
|
||||
"BookmarkSvc.Save": policyAuthenticated,
|
||||
"BookmarkSvc.Delete": policyAuthenticated,
|
||||
"ServerSvc.List": policyAdmin,
|
||||
"ServerSvc.Save": policyAdmin,
|
||||
"ServerSvc.Delete": policyAdmin,
|
||||
"ServerSvc.Metrics": policyAdmin,
|
||||
"ServerSvc.SftpList": policyAdmin,
|
||||
"ServerSvc.SftpMkdir": policyAdmin,
|
||||
"ServerSvc.SftpRemove": policyAdmin,
|
||||
"ServerSvc.SftpRename": policyAdmin,
|
||||
"DbMgrSvc.List": policyAdmin,
|
||||
"DbMgrSvc.Save": policyAdmin,
|
||||
"DbMgrSvc.Delete": policyAdmin,
|
||||
"DbMgrSvc.Test": policyAdmin,
|
||||
"DbMgrSvc.Databases": policyAdmin,
|
||||
"DbMgrSvc.Tables": policyAdmin,
|
||||
"DbMgrSvc.Query": policyAdmin,
|
||||
"WebSvc.Fetch": policyAuthenticated,
|
||||
"DiskSvc.List": policyAuthenticated,
|
||||
"DiskSvc.DeleteFile": policyAuthenticated,
|
||||
"DiskSvc.CreateShare": policyAuthenticated,
|
||||
"DiskSvc.ListShares": policyAuthenticated,
|
||||
"DiskSvc.DeleteShare": policyAuthenticated,
|
||||
"ProxySvc.List": policyAuthenticated,
|
||||
"ProxySvc.Regions": policyAuthenticated,
|
||||
"ProxySvc.Syncs": policyAdmin,
|
||||
"ProxySvc.Stats": policyAuthenticated,
|
||||
"ProxySvc.SyncNow": policyAdmin,
|
||||
"ProxySvc.CheckNow": policyAdmin,
|
||||
"ProxySvc.ListKeys": policyAdmin,
|
||||
"ProxySvc.CreateKey": policyAdmin,
|
||||
"ProxySvc.DeleteKey": policyAdmin,
|
||||
"SettingSvc.Get": policyAuthenticated,
|
||||
"SettingSvc.Set": policyAdmin,
|
||||
"PaySvc.List": policyAdmin,
|
||||
"PaySvc.Stats": policyAdmin,
|
||||
"PaySvc.UpstreamStatus": policyAdmin,
|
||||
"PaySvc.SaveUpstream": policyAdmin,
|
||||
"PaySvc.TestPay": policyAdmin,
|
||||
"PaySvc.CashierInfo": policyPublic,
|
||||
"PaySvc.CashierStatus": policyPublic,
|
||||
"PaySvc.Watch": policyPublic,
|
||||
"LicenseSvc.Apps": policyAdmin,
|
||||
"LicenseSvc.SaveApp": policyAdmin,
|
||||
"LicenseSvc.DeleteApp": policyAdmin,
|
||||
"LicenseSvc.List": policyAdmin,
|
||||
"LicenseSvc.Stats": policyAdmin,
|
||||
"LicenseSvc.Generate": policyAdmin,
|
||||
"LicenseSvc.Delete": policyAdmin,
|
||||
"MemorySvc.Projects": policyAdmin,
|
||||
"MemorySvc.List": policyAdmin,
|
||||
"MemorySvc.Get": policyAdmin,
|
||||
"MemorySvc.Save": policyAdmin,
|
||||
"MemorySvc.Delete": policyAdmin,
|
||||
"MemorySvc.McpInfo": policyAdmin,
|
||||
"MemorySvc.ResetToken": policyAdmin,
|
||||
}
|
||||
|
||||
// AuthGuard 唯一 RPC 鉴权边界:token 由前端请求拦截器写入 ctx.State["token"],
|
||||
// SQLite 会话表校验有效性,命中后用户经 SetUserValue 传给服务层
|
||||
type AuthGuard struct {
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
func (g *AuthGuard) Guard(ctx fun.Ctx) error {
|
||||
policy := endpointPolicies[ctx.ServiceName+"."+ctx.MethodName]
|
||||
switch policy {
|
||||
case policyPublic:
|
||||
return nil
|
||||
case policyDeny:
|
||||
return fun.Error(4003, "接口未登记鉴权策略")
|
||||
}
|
||||
if ctx.RequestCtx == nil {
|
||||
return errors.New("auth guard: missing request context")
|
||||
}
|
||||
token := ctx.State["token"]
|
||||
if token == "" {
|
||||
return fun.Error(authErrCode, "请先登录")
|
||||
}
|
||||
su, err := g.Store.GetSessionUser(token, sessionTTL, sessionRenewAfter)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return fun.Error(authErrCode, "登录已失效,请重新登录")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if su.User.Status != "active" {
|
||||
return fun.Error(4003, "账号已被禁用")
|
||||
}
|
||||
if policy == policyAdmin && su.User.Role != "admin" {
|
||||
return fun.Error(4003, "仅管理员可访问")
|
||||
}
|
||||
ctx.RequestCtx.SetUserValue(sessionKey{}, su)
|
||||
return nil
|
||||
}
|
||||
|
||||
type sessionKey struct{}
|
||||
|
||||
// sessionFrom 取 Guard 校验过的会话;策略表保证进入服务层时必然已设置
|
||||
func sessionFrom(ctx *fasthttp.RequestCtx) (store.SessionUser, error) {
|
||||
if ctx == nil {
|
||||
return store.SessionUser{}, errors.New("auth session: missing request context")
|
||||
}
|
||||
su, ok := ctx.UserValue(sessionKey{}).(store.SessionUser)
|
||||
if !ok || su.User.Id == 0 {
|
||||
return store.SessionUser{}, errors.New("auth session: validated session missing")
|
||||
}
|
||||
return su, nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/dto"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
)
|
||||
|
||||
// BookmarkSvc 个人书签:列表驱动侧边栏快捷入口与书签页
|
||||
type BookmarkSvc struct {
|
||||
fun.Ctx
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
func (s *BookmarkSvc) List() ([]dto.BookmarkView, error) {
|
||||
su, err := sessionFrom(s.RequestCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := s.Store.ListBookmarks(su.User.Id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bookmark: list failed: %w", err)
|
||||
}
|
||||
views := make([]dto.BookmarkView, 0, len(items))
|
||||
for _, b := range items {
|
||||
views = append(views, bookmarkView(b))
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
func (s *BookmarkSvc) Save(d dto.SaveBookmarkDto) (dto.BookmarkView, error) {
|
||||
su, err := sessionFrom(s.RequestCtx)
|
||||
if err != nil {
|
||||
return dto.BookmarkView{}, err
|
||||
}
|
||||
title := strings.TrimSpace(d.Title)
|
||||
raw := strings.TrimSpace(d.Url)
|
||||
if title == "" || len(title) > 64 {
|
||||
return dto.BookmarkView{}, fun.Error(4001, "名称必填且不超过 64 字符")
|
||||
}
|
||||
if raw != "" && !strings.Contains(raw, "://") {
|
||||
raw = "https://" + raw // 裸域名自动补协议
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" ||
|
||||
(u.Scheme != "http" && u.Scheme != "https") {
|
||||
return dto.BookmarkView{}, fun.Error(4001, "网址无效,需 http(s) 地址")
|
||||
}
|
||||
|
||||
b := store.Bookmark{UserID: su.User.Id, Title: title, Url: raw}
|
||||
if d.Id != nil {
|
||||
b.Id = *d.Id
|
||||
}
|
||||
if d.Icon != nil {
|
||||
b.Icon = strings.TrimSpace(*d.Icon)
|
||||
}
|
||||
if d.Sort != nil {
|
||||
b.Sort = *d.Sort
|
||||
}
|
||||
if err := s.Store.SaveBookmark(&b); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return dto.BookmarkView{}, fun.Error(4001, "书签不存在")
|
||||
}
|
||||
return dto.BookmarkView{}, fmt.Errorf("bookmark: save failed: %w", err)
|
||||
}
|
||||
return bookmarkView(b), nil
|
||||
}
|
||||
|
||||
func (s *BookmarkSvc) Delete(d dto.BookmarkIdDto) (dto.OkView, error) {
|
||||
su, err := sessionFrom(s.RequestCtx)
|
||||
if err != nil {
|
||||
return dto.OkView{}, err
|
||||
}
|
||||
if err := s.Store.DeleteBookmark(su.User.Id, d.Id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return dto.OkView{}, fun.Error(4001, "书签不存在")
|
||||
}
|
||||
return dto.OkView{}, fmt.Errorf("bookmark: delete failed: %w", err)
|
||||
}
|
||||
return dto.OkView{Message: "已删除"}, nil
|
||||
}
|
||||
|
||||
func bookmarkView(b store.Bookmark) dto.BookmarkView {
|
||||
return dto.BookmarkView{Id: b.Id, Title: b.Title, Url: b.Url, Icon: b.Icon, Sort: b.Sort}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/db"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/dto"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
)
|
||||
|
||||
var bootTime = time.Now()
|
||||
|
||||
type DashboardSvc struct {
|
||||
fun.Ctx
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
// Stats 仪表盘聚合:会话概况 + 各业务模块计数 + 收入趋势 + 最近动态
|
||||
func (s *DashboardSvc) Stats() (dto.StatsView, error) {
|
||||
ctx := context.Background()
|
||||
q := s.Store.Q()
|
||||
now := time.Now()
|
||||
view := dto.StatsView{UptimeSeconds: int64(time.Since(bootTime).Seconds())}
|
||||
|
||||
// 会话概况
|
||||
var err error
|
||||
if view.TotalUsers, err = s.Store.CountUsers(); err != nil {
|
||||
return view, fmt.Errorf("dashboard: count users failed: %w", err)
|
||||
}
|
||||
if view.ActiveSessions, err = s.Store.CountActiveSessions(); err != nil {
|
||||
return view, fmt.Errorf("dashboard: count sessions failed: %w", err)
|
||||
}
|
||||
if view.TodayLogins, err = s.Store.CountTodayLogins(); err != nil {
|
||||
return view, fmt.Errorf("dashboard: count today logins failed: %w", err)
|
||||
}
|
||||
const days = 14
|
||||
trend, err := s.Store.LoginTrend(days)
|
||||
if err != nil {
|
||||
return view, fmt.Errorf("dashboard: login trend failed: %w", err)
|
||||
}
|
||||
view.LoginTrend = make([]dto.TrendPoint, 0, days)
|
||||
for i := days - 1; i >= 0; i-- {
|
||||
day := now.AddDate(0, 0, -int(i)).Format("2006-01-02")
|
||||
view.LoginTrend = append(view.LoginTrend, dto.TrendPoint{Day: day, Count: trend[day]})
|
||||
}
|
||||
|
||||
// 业务模块计数
|
||||
view.Servers, _ = s.Store.CountServers()
|
||||
view.Proxies, _ = q.CountProxiesAll(ctx)
|
||||
view.AliveProxies, _ = s.Store.CountAliveProxies()
|
||||
view.LicenseApps, _ = q.CountLicenseApps(ctx)
|
||||
view.Dbconns, _ = q.CountDbconns(ctx)
|
||||
view.Files, _ = q.CountFilesAll(ctx)
|
||||
view.Memories, _ = q.CountMemories(ctx)
|
||||
if ps, err := s.Store.MemoryProjects(); err == nil {
|
||||
view.MemoryProjects = int64(len(ps))
|
||||
}
|
||||
if ls, err := q.LicenseCardStatsAll(ctx, now.Unix()); err == nil {
|
||||
view.LicenseCards, view.LicenseActive = ls.Total, anyI64(ls.Active)
|
||||
}
|
||||
if su, err := sessionFrom(s.RequestCtx); err == nil {
|
||||
view.Bookmarks, _ = q.CountBookmarksByUser(ctx, su.User.Id)
|
||||
view.VaultItems, _ = q.CountVaultByUser(ctx, su.User.Id)
|
||||
}
|
||||
|
||||
// 支付:今日 + 累计 + 14 天收入趋势 + 最近成交
|
||||
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Unix()
|
||||
if st, err := q.PayOrderStats(ctx, dayStart); err == nil {
|
||||
view.TodayOrders, view.TodayMoney = st.Cnt, anyI64(st.Money)
|
||||
}
|
||||
if st, err := q.PayOrderStats(ctx, 0); err == nil {
|
||||
view.TotalMoney, view.PendingOrders = anyI64(st.Money), anyI64(st.Pending)
|
||||
}
|
||||
daily := map[string]db.PayDailyPaidRow{}
|
||||
if rows, err := q.PayDailyPaid(ctx, dayStart-int64((days-1)*86400)); err == nil {
|
||||
for _, r := range rows {
|
||||
daily[r.Day] = r
|
||||
}
|
||||
}
|
||||
view.PayTrend = make([]dto.PayTrendPoint, 0, days)
|
||||
for i := days - 1; i >= 0; i-- {
|
||||
t := now.AddDate(0, 0, -int(i))
|
||||
p := dto.PayTrendPoint{Day: t.Format("2006-01-02")}
|
||||
if r, ok := daily[p.Day]; ok {
|
||||
p.Money, p.Count = anyI64(r.Money), r.Cnt
|
||||
}
|
||||
view.PayTrend = append(view.PayTrend, p)
|
||||
}
|
||||
if rows, err := q.RecentPaidOrders(ctx, 6); err == nil {
|
||||
view.RecentOrders = make([]dto.RecentOrderView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
subject := r.Subject
|
||||
if subject == "" {
|
||||
subject = r.TradeNo
|
||||
}
|
||||
view.RecentOrders = append(view.RecentOrders, dto.RecentOrderView{
|
||||
TradeNo: r.TradeNo, Subject: subject, Money: r.Money, PaidAt: r.PaidAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
if ms, err := s.Store.ListMemories("", "", 5); err == nil {
|
||||
view.RecentMemories = make([]dto.RecentMemoryView, 0, len(ms))
|
||||
for _, m := range ms {
|
||||
view.RecentMemories = append(view.RecentMemories, dto.RecentMemoryView{
|
||||
Key: m.Key, Project: m.Project, UpdatedAt: m.UpdatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/config"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/dbx"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/dto"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/vault"
|
||||
)
|
||||
|
||||
// DbMgrSvc 通用数据库管理器:连接密码 AES-GCM 落库,全部端点仅管理员;
|
||||
// 查询固定 20s 超时 + 行数封顶,防止一条烂 SQL 拖死进程
|
||||
type DbMgrSvc struct {
|
||||
fun.Ctx
|
||||
Store *store.Store
|
||||
Cfg *config.Config `fun:"auto"`
|
||||
}
|
||||
|
||||
func (s *DbMgrSvc) List() ([]dto.DbconnView, error) {
|
||||
items, err := s.Store.ListDbconns()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dbm: list failed: %w", err)
|
||||
}
|
||||
views := make([]dto.DbconnView, 0, len(items))
|
||||
for _, e := range items {
|
||||
views = append(views, dbconnView(e))
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
func (s *DbMgrSvc) Save(d dto.SaveDbconnDto) (dto.DbconnView, error) {
|
||||
name := strings.TrimSpace(d.Name)
|
||||
engine := strings.TrimSpace(d.Engine)
|
||||
switch engine {
|
||||
case "mysql", "postgres", "sqlite":
|
||||
default:
|
||||
return dto.DbconnView{}, fun.Error(4001, "引擎只支持 mysql / postgres / sqlite")
|
||||
}
|
||||
if name == "" || len(name) > 64 {
|
||||
return dto.DbconnView{}, fun.Error(4001, "名称必填且不超过 64 字符")
|
||||
}
|
||||
|
||||
e := store.Dbconn{Name: name, Engine: engine}
|
||||
if d.Id != nil {
|
||||
old, err := s.Store.GetDbconn(*d.Id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return dto.DbconnView{}, fun.Error(4001, "连接不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return dto.DbconnView{}, err
|
||||
}
|
||||
e = old
|
||||
e.Name, e.Engine = name, engine
|
||||
}
|
||||
if d.Host != nil {
|
||||
e.Host = strings.TrimSpace(*d.Host)
|
||||
}
|
||||
if d.Port != nil {
|
||||
e.Port = *d.Port
|
||||
}
|
||||
if d.Username != nil {
|
||||
e.Username = strings.TrimSpace(*d.Username)
|
||||
}
|
||||
if d.Database != nil {
|
||||
e.Database = strings.TrimSpace(*d.Database)
|
||||
}
|
||||
if d.Params != nil {
|
||||
e.Params = strings.TrimSpace(*d.Params)
|
||||
}
|
||||
if d.Password != nil {
|
||||
pw := *d.Password
|
||||
if pw == "" {
|
||||
e.PasswordEnc = ""
|
||||
} else {
|
||||
enc, err := vault.Encrypt(s.Cfg.VaultKey, pw)
|
||||
if err != nil {
|
||||
return dto.DbconnView{}, err
|
||||
}
|
||||
e.PasswordEnc = enc
|
||||
}
|
||||
}
|
||||
if e.Engine != "sqlite" && e.Host == "" {
|
||||
return dto.DbconnView{}, fun.Error(4001, "主机地址必填")
|
||||
}
|
||||
if e.Engine == "sqlite" && e.Database == "" {
|
||||
return dto.DbconnView{}, fun.Error(4001, "sqlite 需在「数据库」填文件路径")
|
||||
}
|
||||
if err := s.Store.SaveDbconn(&e); err != nil {
|
||||
return dto.DbconnView{}, fmt.Errorf("dbm: save failed: %w", err)
|
||||
}
|
||||
return dbconnView(e), nil
|
||||
}
|
||||
|
||||
func (s *DbMgrSvc) Delete(d dto.DbconnIdDto) (dto.OkView, error) {
|
||||
if err := s.Store.DeleteDbconn(d.Id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return dto.OkView{}, fun.Error(4001, "连接不存在")
|
||||
}
|
||||
return dto.OkView{}, fmt.Errorf("dbm: delete failed: %w", err)
|
||||
}
|
||||
return dto.OkView{Message: "已删除"}, nil
|
||||
}
|
||||
|
||||
// openConn 解密码 + 建连 + ping
|
||||
func openConn(st *store.Store, key []byte, id int64, database string) (*sql.DB, store.Dbconn, error) {
|
||||
e, err := st.GetDbconn(id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, e, fun.Error(4001, "连接不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, e, err
|
||||
}
|
||||
pw := ""
|
||||
if e.PasswordEnc != "" {
|
||||
pw, err = vault.Decrypt(key, e.PasswordEnc)
|
||||
if err != nil {
|
||||
return nil, e, fmt.Errorf("dbm: decrypt password failed: %w", err)
|
||||
}
|
||||
}
|
||||
d, err := dbx.Open(dbx.Conn{
|
||||
Engine: e.Engine, Host: e.Host, Port: e.Port, Username: e.Username,
|
||||
Password: pw, Database: e.Database, Params: e.Params,
|
||||
}, database)
|
||||
return d, e, err
|
||||
}
|
||||
|
||||
func (s *DbMgrSvc) open(id int64, database string) (*sql.DB, store.Dbconn, error) {
|
||||
return openConn(s.Store, s.Cfg.VaultKey, id, database)
|
||||
}
|
||||
|
||||
// Test 连通性测试
|
||||
func (s *DbMgrSvc) Test(d dto.DbconnIdDto) (dto.OkView, error) {
|
||||
c, _, err := s.open(d.Id, "")
|
||||
if err != nil {
|
||||
return dto.OkView{}, fun.Error(4001, err.Error())
|
||||
}
|
||||
defer c.Close()
|
||||
return dto.OkView{Message: "连接成功"}, nil
|
||||
}
|
||||
|
||||
func (s *DbMgrSvc) Databases(d dto.DbScopeDto) (dto.DbDatabasesView, error) {
|
||||
c, e, err := s.open(d.Id, "")
|
||||
if err != nil {
|
||||
return dto.DbDatabasesView{}, err
|
||||
}
|
||||
defer c.Close()
|
||||
names, err := dbx.Databases(c, e.Engine)
|
||||
if err != nil {
|
||||
return dto.DbDatabasesView{}, fmt.Errorf("dbm: list databases failed: %w", err)
|
||||
}
|
||||
return dto.DbDatabasesView{Names: names}, nil
|
||||
}
|
||||
|
||||
func (s *DbMgrSvc) Tables(d dto.DbScopeDto) (dto.DbTablesView, error) {
|
||||
db := ""
|
||||
if d.Database != nil {
|
||||
db = *d.Database
|
||||
}
|
||||
c, e, err := s.open(d.Id, db)
|
||||
if err != nil {
|
||||
return dto.DbTablesView{}, err
|
||||
}
|
||||
defer c.Close()
|
||||
tables, cols, err := dbx.TablesAndColumns(c, e.Engine, db)
|
||||
if err != nil {
|
||||
return dto.DbTablesView{}, fmt.Errorf("dbm: list tables failed: %w", err)
|
||||
}
|
||||
v := dto.DbTablesView{Tables: []dto.DbTableView{}, Columns: []dto.DbColumnView{}}
|
||||
for _, t := range tables {
|
||||
v.Tables = append(v.Tables, dto.DbTableView{Name: t.Name, Type: t.Type})
|
||||
}
|
||||
for _, cl := range cols {
|
||||
v.Columns = append(v.Columns, dto.DbColumnView{
|
||||
Table: cl.Table, Name: cl.Name, Type: cl.Type, Pk: cl.Pk,
|
||||
})
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
const dbQueryTimeout = 20 * time.Second
|
||||
|
||||
// Query 执行单条 SQL:SELECT 回结果集(JSON 行串),其余回 affected rows
|
||||
func (s *DbMgrSvc) Query(d dto.DbQueryDto) (dto.DbQueryView, error) {
|
||||
sqlText := strings.TrimSpace(d.Sql)
|
||||
if sqlText == "" {
|
||||
return dto.DbQueryView{}, fun.Error(4001, "SQL 不能为空")
|
||||
}
|
||||
db := ""
|
||||
if d.Database != nil {
|
||||
db = *d.Database
|
||||
}
|
||||
c, _, err := s.open(d.Id, db)
|
||||
if err != nil {
|
||||
return dto.DbQueryView{}, err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
maxRows := int64(500)
|
||||
if d.MaxRows != nil && *d.MaxRows > 0 {
|
||||
maxRows = *d.MaxRows
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dbQueryTimeout)
|
||||
defer cancel()
|
||||
res, err := dbx.Query(ctx, c, sqlText, maxRows)
|
||||
if err != nil {
|
||||
return dto.DbQueryView{}, fun.Error(4002, "执行失败:"+err.Error())
|
||||
}
|
||||
rowsJson, _ := json.Marshal(res.Rows)
|
||||
return dto.DbQueryView{
|
||||
IsSelect: res.IsSelect,
|
||||
Columns: res.Columns,
|
||||
RowsJson: string(rowsJson),
|
||||
RowCount: res.RowCount,
|
||||
AffectedRows: res.AffectedRows,
|
||||
Truncated: res.Truncated,
|
||||
ElapsedMs: res.ElapsedMs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func dbconnView(e store.Dbconn) dto.DbconnView {
|
||||
return dto.DbconnView{
|
||||
Id: e.Id,
|
||||
Name: e.Name,
|
||||
Engine: e.Engine,
|
||||
Host: e.Host,
|
||||
Port: e.Port,
|
||||
Username: e.Username,
|
||||
Database: e.Database,
|
||||
HasPassword: e.PasswordEnc != "",
|
||||
CreatedAt: e.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
"github.com/xuri/excelize/v2"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/config"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/dbx"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
)
|
||||
|
||||
const exportRowCap = 50000
|
||||
|
||||
// DbExportHandler POST /dbm/export 表单字段:id, db, sql, format(json|csv|xlsx)
|
||||
func DbExportHandler(st *store.Store, cfg *config.Config) fun.RouteHandler {
|
||||
return func(rc *fun.RouteCtx) error {
|
||||
ctx := rc.RequestCtx
|
||||
if err := routeAdmin(st, ctx); err != nil {
|
||||
ctx.SetBodyString(`{"status":2,"msg":"未授权"}`)
|
||||
return nil
|
||||
}
|
||||
id, _ := strconv.ParseInt(string(ctx.QueryArgs().Peek("id")), 10, 64)
|
||||
if id == 0 {
|
||||
id, _ = strconv.ParseInt(rc.Data["id"], 10, 64)
|
||||
}
|
||||
dbName := rc.Data["db"]
|
||||
sqlText := strings.TrimSpace(rc.Data["sql"])
|
||||
format := rc.Data["format"]
|
||||
if format == "" {
|
||||
format = "json"
|
||||
}
|
||||
if id == 0 || sqlText == "" {
|
||||
ctx.SetStatusCode(400)
|
||||
ctx.SetBodyString(`{"status":2,"msg":"参数不完整"}`)
|
||||
return nil
|
||||
}
|
||||
c, _, err := openConn(st, cfg.VaultKey, id, dbName)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(502)
|
||||
ctx.SetBodyString("连接失败:" + err.Error())
|
||||
return nil
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
qctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
res, err := dbx.Query(qctx, c, sqlText, exportRowCap)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(500)
|
||||
ctx.SetBodyString("执行失败:" + err.Error())
|
||||
return nil
|
||||
}
|
||||
if !res.IsSelect {
|
||||
ctx.SetStatusCode(400)
|
||||
ctx.SetBodyString(`{"status":2,"msg":"非查询语句没有可导出的结果集"}`)
|
||||
return nil
|
||||
}
|
||||
|
||||
var body []byte
|
||||
var ext, mime string
|
||||
switch format {
|
||||
case "xlsx":
|
||||
body, err = toXLSX(res)
|
||||
ext, mime = "xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
case "csv":
|
||||
body, err = toCSV(res)
|
||||
ext, mime = "csv", "text/csv; charset=utf-8"
|
||||
default:
|
||||
ext, mime = "json", "application/json"
|
||||
body, err = toJSONRows(res)
|
||||
}
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(500)
|
||||
ctx.SetBodyString("导出失败:" + err.Error())
|
||||
return nil
|
||||
}
|
||||
fname := fmt.Sprintf("export_%d.%s", time.Now().Unix(), ext)
|
||||
ctx.Response.Header.Set("Content-Disposition", "attachment; filename=\""+fname+"\"")
|
||||
ctx.SetContentType(mime)
|
||||
ctx.SetBody(body)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func toJSONRows(res *dbx.Result) ([]byte, error) {
|
||||
list := make([]map[string]any, 0, len(res.Rows))
|
||||
for _, row := range res.Rows {
|
||||
obj := map[string]any{}
|
||||
for i, col := range res.Columns {
|
||||
obj[col] = row[i]
|
||||
}
|
||||
list = append(list, obj)
|
||||
}
|
||||
return json.MarshalIndent(list, "", " ")
|
||||
}
|
||||
|
||||
func toCSV(res *dbx.Result) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
buf.Write([]byte{0xEF, 0xBB, 0xBF}) // BOM,Excel 打开不乱码
|
||||
w := csv.NewWriter(&buf)
|
||||
if err := w.Write(res.Columns); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range res.Rows {
|
||||
rec := make([]string, len(row))
|
||||
for i, v := range row {
|
||||
rec[i] = cellText(v)
|
||||
}
|
||||
if err := w.Write(rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
w.Flush()
|
||||
return buf.Bytes(), w.Error()
|
||||
}
|
||||
|
||||
func toXLSX(res *dbx.Result) ([]byte, error) {
|
||||
f := excelize.NewFile()
|
||||
defer f.Close()
|
||||
sheet := "Sheet1"
|
||||
for i, c := range res.Columns {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||
if err := f.SetCellValue(sheet, cell, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for ri, row := range res.Rows {
|
||||
for ci, v := range row {
|
||||
cell, _ := excelize.CoordinatesToCellName(ci+1, ri+2)
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if err := f.SetCellValue(sheet, cell, v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
buf, err := f.WriteToBuffer()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func cellText(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case bool:
|
||||
if t {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
default:
|
||||
return fmt.Sprint(t)
|
||||
}
|
||||
}
|
||||
|
||||
// DbImportHandler POST /dbm/import multipart:file + 字段 id/db/table;
|
||||
// 按扩展名解析 json(对象数组) / csv / xlsx(首行为表头),列名与表列交集后批量插入
|
||||
func DbImportHandler(st *store.Store, cfg *config.Config) fun.RouteHandler {
|
||||
return func(rc *fun.RouteCtx) error {
|
||||
ctx := rc.RequestCtx
|
||||
if err := routeAdmin(st, ctx); err != nil {
|
||||
ctx.SetBodyString(`{"status":2,"msg":"未授权"}`)
|
||||
return nil
|
||||
}
|
||||
id, _ := strconv.ParseInt(string(ctx.QueryArgs().Peek("id")), 10, 64)
|
||||
dbName := string(ctx.QueryArgs().Peek("db"))
|
||||
table := strings.TrimSpace(string(ctx.QueryArgs().Peek("table")))
|
||||
if id == 0 || table == "" {
|
||||
ctx.SetStatusCode(400)
|
||||
ctx.SetBodyString(`{"status":2,"msg":"参数不完整"}`)
|
||||
return nil
|
||||
}
|
||||
fh, err := ctx.FormFile("file")
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(400)
|
||||
ctx.SetBodyString(`{"status":2,"msg":"缺少文件"}`)
|
||||
return nil
|
||||
}
|
||||
src, err := fh.Open()
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(400)
|
||||
ctx.SetBodyString(`{"status":2,"msg":"读取上传失败"}`)
|
||||
return nil
|
||||
}
|
||||
defer src.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(src, 64<<20))
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(400)
|
||||
ctx.SetBodyString(`{"status":2,"msg":"读取文件失败"}`)
|
||||
return nil
|
||||
}
|
||||
rows, err := parseImport(strings.ToLower(fh.Filename), raw)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(400)
|
||||
ctx.SetBodyString(`{"status":2,"msg":"` + err.Error() + `"}`)
|
||||
return nil
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
ctx.SetStatusCode(400)
|
||||
ctx.SetBodyString(`{"status":2,"msg":"文件里没有数据行"}`)
|
||||
return nil
|
||||
}
|
||||
|
||||
c, e, err := openConn(st, cfg.VaultKey, id, dbName)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(502)
|
||||
ctx.SetBodyString(`{"status":2,"msg":"连接失败"}`)
|
||||
return nil
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// 表名校验:必须在 introspection 列表里,顺便拿到列清单做交集
|
||||
tables, cols, err := dbx.TablesAndColumns(c, e.Engine, dbName)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(500)
|
||||
ctx.SetBodyString(`{"status":2,"msg":"读表结构失败"}`)
|
||||
return nil
|
||||
}
|
||||
validTable := false
|
||||
tableCols := map[string]bool{}
|
||||
for _, t := range tables {
|
||||
if t.Name == table {
|
||||
validTable = true
|
||||
}
|
||||
}
|
||||
for _, cl := range cols {
|
||||
if cl.Table == table {
|
||||
tableCols[cl.Name] = true
|
||||
}
|
||||
}
|
||||
if !validTable {
|
||||
ctx.SetStatusCode(400)
|
||||
ctx.SetBodyString(`{"status":2,"msg":"表不存在"}`)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 列名 = 文件表头 ∩ 表列
|
||||
fileCols := orderedKeys(rows)
|
||||
useCols := []string{}
|
||||
for _, k := range fileCols {
|
||||
if tableCols[k] {
|
||||
useCols = append(useCols, k)
|
||||
}
|
||||
}
|
||||
if len(useCols) == 0 {
|
||||
ctx.SetStatusCode(400)
|
||||
ctx.SetBodyString(`{"status":2,"msg":"文件列名与表列没有交集"}`)
|
||||
return nil
|
||||
}
|
||||
|
||||
inserted, skipped, err := bulkInsert(c, e.Engine, table, useCols, rows)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(500)
|
||||
ctx.SetBodyString(`{"status":2,"msg":"导入失败:` + err.Error() + `"}`)
|
||||
return nil
|
||||
}
|
||||
ctx.SetContentType("application/json")
|
||||
fmt.Fprintf(ctx, `{"status":0,"inserted":%d,"skipped":%d}`, inserted, skipped)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// bulkInsert 逐行插入(不开事务:PG 事务内单行失败会整批回滚,
|
||||
// 逐行保证「能导多少导多少」,跳过坏行计数返回)。单次最多 1 万行。
|
||||
func bulkInsert(d *sql.DB, engine, table string, cols []string, rows []map[string]any) (int64, int64, error) {
|
||||
if len(rows) > 10000 {
|
||||
rows = rows[:10000]
|
||||
}
|
||||
quote := func(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` }
|
||||
if engine == "mysql" {
|
||||
quote = func(s string) string { return "`" + strings.ReplaceAll(s, "`", "``") + "`" }
|
||||
}
|
||||
quoted := make([]string, len(cols))
|
||||
placeholders := make([]string, len(cols))
|
||||
for i, c := range cols {
|
||||
quoted[i] = quote(c)
|
||||
if engine == "postgres" {
|
||||
placeholders[i] = "$" + strconv.Itoa(i+1)
|
||||
} else {
|
||||
placeholders[i] = "?"
|
||||
}
|
||||
}
|
||||
stmt := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
|
||||
quote(table), strings.Join(quoted, ","), strings.Join(placeholders, ","))
|
||||
ps, err := d.Prepare(stmt)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer ps.Close()
|
||||
var inserted, skipped int64
|
||||
for _, r := range rows {
|
||||
vals := make([]any, len(cols))
|
||||
for i, c := range cols {
|
||||
vals[i] = r[c]
|
||||
}
|
||||
if _, err := ps.Exec(vals...); err != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
inserted++
|
||||
}
|
||||
return inserted, skipped, nil
|
||||
}
|
||||
|
||||
// parseImport 按扩展名分派:.json 对象数组 / .xlsx 首行表头 / 其余按 CSV(兼容 BOM)
|
||||
func parseImport(filename string, raw []byte) ([]map[string]any, error) {
|
||||
switch {
|
||||
case strings.HasSuffix(filename, ".json"):
|
||||
var rows []map[string]any
|
||||
if err := json.Unmarshal(raw, &rows); err != nil {
|
||||
return nil, fmt.Errorf("JSON 解析失败:文件应为对象数组 [{...}, ...]")
|
||||
}
|
||||
return rows, nil
|
||||
case strings.HasSuffix(filename, ".xlsx"):
|
||||
f, err := excelize.OpenReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Excel 解析失败")
|
||||
}
|
||||
defer f.Close()
|
||||
sheets := f.GetSheetList()
|
||||
if len(sheets) == 0 {
|
||||
return nil, fmt.Errorf("Excel 文件没有工作表")
|
||||
}
|
||||
recs, err := f.GetRows(sheets[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取工作表失败")
|
||||
}
|
||||
return rowsFromTable(recs), nil
|
||||
default:
|
||||
r := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(raw, []byte{0xEF, 0xBB, 0xBF})))
|
||||
r.FieldsPerRecord = -1 // 容忍长短不一的行
|
||||
recs, err := r.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CSV 解析失败")
|
||||
}
|
||||
return rowsFromTable(recs), nil
|
||||
}
|
||||
}
|
||||
|
||||
// rowsFromTable 二维表(首行表头)→ 对象数组
|
||||
func rowsFromTable(recs [][]string) []map[string]any {
|
||||
if len(recs) < 2 {
|
||||
return nil
|
||||
}
|
||||
head := recs[0]
|
||||
out := []map[string]any{}
|
||||
for _, rec := range recs[1:] {
|
||||
m := map[string]any{}
|
||||
for i, h := range head {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" {
|
||||
continue
|
||||
}
|
||||
if i < len(rec) {
|
||||
m[h] = rec[i]
|
||||
}
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// orderedKeys 取文件列名(按首行出现顺序)
|
||||
func orderedKeys(rows []map[string]any) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, r := range rows {
|
||||
for k := range r {
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/config"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/dto"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
)
|
||||
|
||||
const shareCodeLen = 10
|
||||
const shareAlphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
// DiskSvc 网盘:文件列表/删除/分享链接管理(按用户隔离,属主校验)
|
||||
type DiskSvc struct {
|
||||
fun.Ctx
|
||||
Store *store.Store
|
||||
Cfg *config.Config
|
||||
}
|
||||
|
||||
func shareCode() (string, error) {
|
||||
buf := make([]byte, shareCodeLen)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i := range buf {
|
||||
buf[i] = shareAlphabet[int(buf[i])%len(shareAlphabet)]
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func fileViews(files []store.File) []dto.FileView {
|
||||
out := make([]dto.FileView, 0, len(files))
|
||||
for _, f := range files {
|
||||
out = append(out, dto.FileView{
|
||||
Id: f.Id, Name: f.Name, Size: f.Size, Mime: f.Mime, CreatedAt: f.CreatedAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func shareViews(shares []store.Share) []dto.ShareView {
|
||||
now := time.Now().Unix()
|
||||
out := make([]dto.ShareView, 0, len(shares))
|
||||
for _, s := range shares {
|
||||
out = append(out, dto.ShareView{
|
||||
Id: s.Id, Code: s.Code, FileName: s.FileName,
|
||||
ExpiresAt: s.ExpiresAt, Expired: s.ExpiresAt > 0 && now > s.ExpiresAt,
|
||||
Downloads: s.Downloads, CreatedAt: s.CreatedAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *DiskSvc) List() ([]dto.FileView, error) {
|
||||
su, err := sessionFrom(s.RequestCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files, err := s.Store.ListFiles(su.User.Id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("disk: list failed: %w", err)
|
||||
}
|
||||
return fileViews(files), nil
|
||||
}
|
||||
|
||||
// DeleteFile 删文件:库记录 + 磁盘文件 + 关联分享一起清
|
||||
func (s *DiskSvc) DeleteFile(d dto.IdDto) (dto.OkView, error) {
|
||||
su, err := sessionFrom(s.RequestCtx)
|
||||
if err != nil {
|
||||
return dto.OkView{}, err
|
||||
}
|
||||
f, err := s.Store.GetFile(d.Id)
|
||||
if errors.Is(err, store.ErrNotFound) || f.UserID != su.User.Id {
|
||||
return dto.OkView{}, fun.Error(4004, "文件不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return dto.OkView{}, err
|
||||
}
|
||||
if _, err := s.Store.DeleteFile(d.Id, su.User.Id); err != nil {
|
||||
return dto.OkView{}, fmt.Errorf("disk: delete failed: %w", err)
|
||||
}
|
||||
_ = s.Store.DeleteSharesByFile(d.Id)
|
||||
_ = os.Remove(filepath.Join(s.Cfg.DiskDir, f.Stored))
|
||||
return dto.OkView{Message: "已删除"}, nil
|
||||
}
|
||||
|
||||
// CreateShare ExpireMinutes=0 永久;>0 限时分享
|
||||
func (s *DiskSvc) CreateShare(d dto.CreateShareDto) (dto.ShareView, error) {
|
||||
su, err := sessionFrom(s.RequestCtx)
|
||||
if err != nil {
|
||||
return dto.ShareView{}, err
|
||||
}
|
||||
f, err := s.Store.GetFile(d.FileId)
|
||||
if errors.Is(err, store.ErrNotFound) || f.UserID != su.User.Id {
|
||||
return dto.ShareView{}, fun.Error(4004, "文件不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return dto.ShareView{}, err
|
||||
}
|
||||
if d.ExpireMinutes < 0 || d.ExpireMinutes > 60*24*365 {
|
||||
return dto.ShareView{}, fun.Error(4001, "有效期不合法")
|
||||
}
|
||||
code, err := shareCode()
|
||||
if err != nil {
|
||||
return dto.ShareView{}, err
|
||||
}
|
||||
var expires int64
|
||||
if d.ExpireMinutes > 0 {
|
||||
expires = time.Now().Add(time.Duration(d.ExpireMinutes) * time.Minute).Unix()
|
||||
}
|
||||
sh := store.Share{
|
||||
Code: code, FileID: f.Id, UserID: su.User.Id,
|
||||
FileName: f.Name, ExpiresAt: expires,
|
||||
}
|
||||
if err := s.Store.InsertShare(&sh); err != nil {
|
||||
return dto.ShareView{}, fmt.Errorf("disk: create share failed: %w", err)
|
||||
}
|
||||
return shareViews([]store.Share{sh})[0], nil
|
||||
}
|
||||
|
||||
func (s *DiskSvc) ListShares() ([]dto.ShareView, error) {
|
||||
su, err := sessionFrom(s.RequestCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shares, err := s.Store.ListShares(su.User.Id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("disk: list shares failed: %w", err)
|
||||
}
|
||||
return shareViews(shares), nil
|
||||
}
|
||||
|
||||
func (s *DiskSvc) DeleteShare(d dto.IdDto) (dto.OkView, error) {
|
||||
su, err := sessionFrom(s.RequestCtx)
|
||||
if err != nil {
|
||||
return dto.OkView{}, err
|
||||
}
|
||||
n, err := s.Store.DeleteShare(d.Id, su.User.Id)
|
||||
if err != nil {
|
||||
return dto.OkView{}, fmt.Errorf("disk: delete share failed: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return dto.OkView{}, fun.Error(4004, "分享不存在")
|
||||
}
|
||||
return dto.OkView{Message: "已删除"}, nil
|
||||
}
|
||||
|
||||
// safeStored 落盘文件名清洗:只留安全字符,防路径穿越
|
||||
func safeStored(name string) string {
|
||||
name = strings.ReplaceAll(name, "/", "_")
|
||||
name = strings.ReplaceAll(name, "\\", "_")
|
||||
name = strings.ReplaceAll(name, "..", "_")
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
"github.com/valyala/fasthttp"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/config"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
)
|
||||
|
||||
// routeUser 自定义路由的登录用户校验(区别于 routeAdmin:任何登录用户可用)
|
||||
func routeUser(st *store.Store, ctx *fasthttp.RequestCtx) (store.SessionUser, error) {
|
||||
token := string(ctx.QueryArgs().Peek("token"))
|
||||
if token == "" {
|
||||
ctx.SetStatusCode(fasthttp.StatusUnauthorized)
|
||||
return store.SessionUser{}, errors.New("missing token")
|
||||
}
|
||||
su, err := st.GetSessionUser(token, sessionTTL, sessionRenewAfter)
|
||||
if err != nil || su.User.Status != "active" {
|
||||
ctx.SetStatusCode(fasthttp.StatusUnauthorized)
|
||||
return store.SessionUser{}, errors.New("invalid token")
|
||||
}
|
||||
return su, nil
|
||||
}
|
||||
|
||||
func routeJSONErr(ctx *fasthttp.RequestCtx, code int, msg string) {
|
||||
ctx.SetStatusCode(code)
|
||||
ctx.SetContentType("application/json")
|
||||
ctx.SetBodyString(`{"status":2,"msg":"` + msg + `"}`)
|
||||
}
|
||||
|
||||
// DiskUploadHandler POST /disk/upload?token= multipart 字段 file
|
||||
func DiskUploadHandler(st *store.Store, cfg *config.Config) fun.RouteHandler {
|
||||
return func(rc *fun.RouteCtx) error {
|
||||
ctx := rc.RequestCtx
|
||||
su, err := routeUser(st, ctx)
|
||||
if err != nil {
|
||||
routeJSONErr(ctx, 401, "未登录或登录已失效")
|
||||
return nil
|
||||
}
|
||||
fh, err := ctx.FormFile("file")
|
||||
if err != nil {
|
||||
routeJSONErr(ctx, 400, "缺少文件")
|
||||
return nil
|
||||
}
|
||||
src, err := fh.Open()
|
||||
if err != nil {
|
||||
routeJSONErr(ctx, 400, "读取上传失败")
|
||||
return nil
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// 落盘名 = 时间戳+随机后缀(不沿用原名,天然免冲突与穿越)
|
||||
stored := fmt.Sprintf("%d_%s", time.Now().UnixNano(), safeStored(filepath.Base(fh.Filename)))
|
||||
dstPath := filepath.Join(cfg.DiskDir, stored)
|
||||
dst, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
routeJSONErr(ctx, 500, "写入失败")
|
||||
return nil
|
||||
}
|
||||
size, err := io.Copy(dst, src)
|
||||
dst.Close()
|
||||
if err != nil {
|
||||
_ = os.Remove(dstPath)
|
||||
routeJSONErr(ctx, 500, "保存失败")
|
||||
return nil
|
||||
}
|
||||
|
||||
mimeType := fh.Header.Get("Content-Type")
|
||||
f := store.File{
|
||||
UserID: su.User.Id, Name: filepath.Base(fh.Filename),
|
||||
Stored: stored, Size: size, Mime: mimeType,
|
||||
}
|
||||
if err := st.InsertFile(&f); err != nil {
|
||||
_ = os.Remove(dstPath)
|
||||
routeJSONErr(ctx, 500, "记录失败")
|
||||
return nil
|
||||
}
|
||||
ctx.SetContentType("application/json")
|
||||
fmt.Fprintf(ctx, `{"status":0,"id":%d,"name":%q,"size":%d}`, f.Id, f.Name, f.Size)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// serveDiskFile 共用:按原始文件名附件回传
|
||||
func serveDiskFile(ctx *fasthttp.RequestCtx, dir, stored, name, mimeType string, size int64) {
|
||||
path := filepath.Join(dir, stored)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(404)
|
||||
ctx.SetBodyString("文件已不存在")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
// RFC 5987:中文文件名走 filename*,再补一份 filename 兜底
|
||||
disp := "attachment; filename*=UTF-8''" + url.PathEscape(name)
|
||||
ctx.Response.Header.Set("Content-Disposition", disp)
|
||||
if mimeType != "" {
|
||||
ctx.SetContentType(mimeType)
|
||||
} else if mt := mime.TypeByExtension(filepath.Ext(name)); mt != "" {
|
||||
ctx.SetContentType(mt)
|
||||
} else {
|
||||
ctx.SetContentType("application/octet-stream")
|
||||
}
|
||||
ctx.Response.Header.Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
_, _ = io.Copy(ctx, f)
|
||||
}
|
||||
|
||||
// DiskDownloadHandler GET /disk/download?id=&token= 属主下载
|
||||
func DiskDownloadHandler(st *store.Store, cfg *config.Config) fun.RouteHandler {
|
||||
return func(rc *fun.RouteCtx) error {
|
||||
ctx := rc.RequestCtx
|
||||
su, err := routeUser(st, ctx)
|
||||
if err != nil {
|
||||
routeJSONErr(ctx, 401, "未登录或登录已失效")
|
||||
return nil
|
||||
}
|
||||
id, _ := strconv.ParseInt(string(ctx.QueryArgs().Peek("id")), 10, 64)
|
||||
f, err := st.GetFile(id)
|
||||
if err != nil || f.UserID != su.User.Id {
|
||||
ctx.SetStatusCode(404)
|
||||
ctx.SetBodyString("文件不存在")
|
||||
return nil
|
||||
}
|
||||
serveDiskFile(ctx, cfg.DiskDir, f.Stored, f.Name, f.Mime, f.Size)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ShareDownloadHandler GET /d/* 公开分享下载:code 即 wildcard 路径,过期返回 410
|
||||
func ShareDownloadHandler(st *store.Store, cfg *config.Config) fun.RouteHandler {
|
||||
return func(rc *fun.RouteCtx) error {
|
||||
ctx := rc.RequestCtx
|
||||
code := strings.Trim(rc.Wildcard, "/")
|
||||
if code == "" {
|
||||
ctx.SetStatusCode(404)
|
||||
ctx.SetBodyString("链接无效")
|
||||
return nil
|
||||
}
|
||||
v, err := st.GetShareFile(code)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(404)
|
||||
ctx.SetBodyString("分享链接不存在或已删除")
|
||||
return nil
|
||||
}
|
||||
if v.ExpiresAt > 0 && time.Now().Unix() > v.ExpiresAt {
|
||||
ctx.SetStatusCode(410)
|
||||
ctx.SetContentType("text/html; charset=utf-8")
|
||||
ctx.SetBodyString(`<!doctype html><meta charset="utf-8"><body style="font-family:sans-serif;display:flex;height:100vh;align-items:center;justify-content:center;background:#f8f6f6"><div style="text-align:center"><h1 style="font-size:48px;margin:0">链接已过期</h1><p style="color:#8a857e">这个分享已经过了有效期,找文件的主人再要一个新链接吧</p></div></body>`)
|
||||
return nil
|
||||
}
|
||||
_ = st.IncShareDownloads(code)
|
||||
serveDiskFile(ctx, cfg.DiskDir, v.Stored, v.FileName, v.Mime, v.Size)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// license.go 授权系统管理:项目(appid/secret)+ 激活卡密(绑域名、按时长计时)。
|
||||
// 对外校验走 /v1/license,仅供其他项目客户端调用。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/db"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/dto"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
)
|
||||
|
||||
// LicenseSvc 授权管理(仅管理员)。
|
||||
type LicenseSvc struct {
|
||||
fun.Ctx
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
const (
|
||||
licenseUnused = 0 // 未使用
|
||||
licenseActive = 1 // 已激活(有效期内)
|
||||
licenseExpired = 2 // 已过期
|
||||
)
|
||||
|
||||
// ---- 项目管理 ----
|
||||
|
||||
func (s *LicenseSvc) Apps() (dto.LicenseAppsView, error) {
|
||||
rows, err := s.Store.Q().ListLicenseApps(context.Background())
|
||||
if err != nil {
|
||||
return dto.LicenseAppsView{}, err
|
||||
}
|
||||
items := make([]dto.LicenseAppView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, dto.LicenseAppView{
|
||||
Id: r.ID, Appid: r.Appid, Name: r.Name,
|
||||
Enabled: r.Enabled, CardCount: r.CardCount, CreatedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
return dto.LicenseAppsView{Items: items}, nil
|
||||
}
|
||||
|
||||
// SaveApp id=0 新建(自动生成 appid+secret);否则改名/开关/重置 secret。
|
||||
func (s *LicenseSvc) SaveApp(d dto.LicenseAppSaveDto) (dto.LicenseAppView, error) {
|
||||
ctx := context.Background()
|
||||
if d.Id == 0 {
|
||||
name := strings.TrimSpace(d.Name)
|
||||
if name == "" || len(name) > 50 {
|
||||
return dto.LicenseAppView{}, fun.Error(4001, "项目名必填且不超过 50 字")
|
||||
}
|
||||
appid := "app-" + randLowerHex(4)
|
||||
id, err := s.Store.Q().CreateLicenseApp(ctx, db.CreateLicenseAppParams{
|
||||
Appid: appid, Secret: "", Name: name, CreatedAt: time.Now().Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return dto.LicenseAppView{}, fmt.Errorf("创建项目失败: %w", err)
|
||||
}
|
||||
return dto.LicenseAppView{Id: id, Appid: appid, Name: name, Enabled: 1}, nil
|
||||
}
|
||||
name := strings.TrimSpace(d.Name)
|
||||
if name == "" || len(name) > 50 {
|
||||
return dto.LicenseAppView{}, fun.Error(4001, "项目名必填且不超过 50 字")
|
||||
}
|
||||
enabled := int64(1)
|
||||
if d.Enabled != nil {
|
||||
enabled = *d.Enabled
|
||||
}
|
||||
if err := s.Store.Q().UpdateLicenseApp(ctx, db.UpdateLicenseAppParams{
|
||||
Name: name, Enabled: enabled, ID: d.Id,
|
||||
}); err != nil {
|
||||
return dto.LicenseAppView{}, err
|
||||
}
|
||||
return dto.LicenseAppView{Id: d.Id, Name: name, Enabled: enabled}, nil
|
||||
}
|
||||
|
||||
func (s *LicenseSvc) DeleteApp(d dto.LicenseIdDto) (dto.OkView, error) {
|
||||
if err := s.Store.Q().DeleteLicenseApp(context.Background(), d.Id); err != nil {
|
||||
return dto.OkView{}, err
|
||||
}
|
||||
return dto.OkView{Message: "已删除"}, nil
|
||||
}
|
||||
|
||||
// ---- 卡密管理 ----
|
||||
|
||||
func licenseStatus(c db.LicenseCard, now int64) int64 {
|
||||
if c.ActivatedAt == 0 {
|
||||
return licenseUnused
|
||||
}
|
||||
if c.ExpiresAt > 0 && c.ExpiresAt <= now { // expires_at=0 表示永久
|
||||
return licenseExpired
|
||||
}
|
||||
return licenseActive
|
||||
}
|
||||
|
||||
func licenseView(c db.LicenseCard, now int64) dto.LicenseCardView {
|
||||
return dto.LicenseCardView{
|
||||
Id: c.ID, Card: c.Card, Hours: c.Hours, Domain: c.Domain,
|
||||
Status: licenseStatus(c, now),
|
||||
ActivatedAt: c.ActivatedAt, ExpiresAt: c.ExpiresAt, CreatedAt: c.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LicenseSvc) List(d dto.LicenseListDto) (dto.LicensePageView, error) {
|
||||
kw := sql.NullString{}
|
||||
if d.Kw != nil {
|
||||
kw = sql.NullString{String: strings.TrimSpace(*d.Kw), Valid: true}
|
||||
}
|
||||
page := max(d.Page, 1)
|
||||
size := min(max(d.PageSize, 1), 200)
|
||||
ctx := context.Background()
|
||||
total, err := s.Store.Q().CountLicenseCards(ctx, db.CountLicenseCardsParams{
|
||||
AppID: d.AppId, Column2: kw.String, Column3: kw, Column4: kw,
|
||||
})
|
||||
if err != nil {
|
||||
return dto.LicensePageView{}, err
|
||||
}
|
||||
rows, err := s.Store.Q().ListLicenseCards(ctx, db.ListLicenseCardsParams{
|
||||
AppID: d.AppId, Column2: kw.String, Column3: kw, Column4: kw,
|
||||
Limit: size, Offset: (page - 1) * size,
|
||||
})
|
||||
if err != nil {
|
||||
return dto.LicensePageView{}, err
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
items := make([]dto.LicenseCardView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, licenseView(r, now))
|
||||
}
|
||||
return dto.LicensePageView{Total: total, Items: items}, nil
|
||||
}
|
||||
|
||||
func (s *LicenseSvc) Stats(d dto.LicenseStatsDto) (dto.LicenseStatsView, error) {
|
||||
now := time.Now().Unix()
|
||||
r, err := s.Store.Q().LicenseCardStats(context.Background(), db.LicenseCardStatsParams{
|
||||
ExpiresAt: now, ExpiresAt_2: now, AppID: d.AppId,
|
||||
})
|
||||
if err != nil {
|
||||
return dto.LicenseStatsView{}, err
|
||||
}
|
||||
return dto.LicenseStatsView{Total: r.Total, Unused: anyI64(r.Unused), Active: anyI64(r.Active), Expired: anyI64(r.Expired)}, nil
|
||||
}
|
||||
|
||||
// Generate 批量生成卡密:大写分组格式(XXXX-XXXX-XXXX-XXXX),一次最多 500。
|
||||
func (s *LicenseSvc) Generate(d dto.LicenseGenDto) (dto.LicenseGenView, error) {
|
||||
if d.AppId <= 0 {
|
||||
return dto.LicenseGenView{}, fun.Error(4001, "请先选择项目")
|
||||
}
|
||||
count := min(max(d.Count, 1), 500)
|
||||
hours := min(max(d.Hours, 0), 24*3650) // 0 = 永久
|
||||
ctx := context.Background()
|
||||
now := time.Now().Unix()
|
||||
cards := make([]string, 0, count)
|
||||
for i := int64(0); i < count; i++ {
|
||||
card := genLicenseCard()
|
||||
if err := s.Store.Q().CreateLicenseCard(ctx, db.CreateLicenseCardParams{
|
||||
AppID: d.AppId, Card: card, Hours: hours, CreatedAt: now,
|
||||
}); err != nil {
|
||||
return dto.LicenseGenView{}, fmt.Errorf("生成卡密失败(%d/%d): %w", i, count, err)
|
||||
}
|
||||
cards = append(cards, card)
|
||||
}
|
||||
return dto.LicenseGenView{Cards: cards}, nil
|
||||
}
|
||||
|
||||
func (s *LicenseSvc) Delete(d dto.LicenseIdDto) (dto.OkView, error) {
|
||||
if err := s.Store.Q().DeleteLicenseCard(context.Background(), d.Id); err != nil {
|
||||
return dto.OkView{}, err
|
||||
}
|
||||
return dto.OkView{Message: "已删除"}, nil
|
||||
}
|
||||
|
||||
// genLicenseCard XXXX-XXXX-XXXX-XXXX 大写卡密,排除易混淆字符(0/O、1/I/L)。
|
||||
func genLicenseCard() string {
|
||||
const chars = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
var sb strings.Builder
|
||||
for i, v := range b {
|
||||
if i > 0 && i%4 == 0 {
|
||||
sb.WriteByte('-')
|
||||
}
|
||||
sb.WriteByte(chars[int(v)%len(chars)])
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// randLowerHex n 字节随机小写 hex。
|
||||
func randLowerHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// license_routes.go 卡密公开校验端点:GET/POST /v1/license?appid=xx&card=xx&domain=xx
|
||||
// 首次调用激活并绑定域名(开始计时),之后仅限同域名校验;供其他项目做网络验证。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/db"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
)
|
||||
|
||||
func LicenseVerifyHandler(st *store.Store) fun.RouteHandler {
|
||||
return func(c *fun.RouteCtx) error {
|
||||
appid := strings.TrimSpace(c.Param("appid"))
|
||||
card := strings.ToUpper(strings.TrimSpace(c.Param("card")))
|
||||
domain := normalizeDomain(c.Param("domain"))
|
||||
if appid == "" || card == "" || domain == "" {
|
||||
return licenseJSON(c, 0, "缺少参数:appid / card / domain", nil)
|
||||
}
|
||||
ctx := context.Background()
|
||||
app, err := st.Q().GetLicenseAppByAppid(ctx, appid)
|
||||
if err != nil || app.Enabled != 1 {
|
||||
return licenseJSON(c, 0, "项目不存在或已停用", nil)
|
||||
}
|
||||
lc, err := st.Q().GetLicenseCard(ctx, db.GetLicenseCardParams{AppID: app.ID, Card: card})
|
||||
if err != nil {
|
||||
return licenseJSON(c, 0, "卡密无效", nil)
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if lc.ActivatedAt == 0 {
|
||||
// 首激:绑域名 + 计时开始;hours=0 为永久卡(expires_at=0 永不过期)
|
||||
expires := int64(0)
|
||||
if lc.Hours > 0 {
|
||||
expires = now + lc.Hours*3600
|
||||
}
|
||||
if err := st.Q().ActivateLicenseCard(ctx, db.ActivateLicenseCardParams{
|
||||
Domain: domain, ActivatedAt: now, ExpiresAt: expires, ID: lc.ID,
|
||||
}); err != nil {
|
||||
return licenseJSON(c, 0, "激活失败", nil)
|
||||
}
|
||||
return licenseJSON(c, 1, "激活成功", map[string]any{
|
||||
"domain": domain, "hours": lc.Hours, "expires_at": expires,
|
||||
})
|
||||
}
|
||||
if lc.Domain != domain {
|
||||
return licenseJSON(c, 0, "卡密已绑定其他域名", nil)
|
||||
}
|
||||
if lc.ExpiresAt > 0 && lc.ExpiresAt <= now {
|
||||
return licenseJSON(c, 0, "卡密已过期", map[string]any{"expires_at": lc.ExpiresAt})
|
||||
}
|
||||
return licenseJSON(c, 1, "验证通过", map[string]any{
|
||||
"domain": lc.Domain, "hours": lc.Hours, "expires_at": lc.ExpiresAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeDomain 取纯主机名:去协议/路径/端口/末尾点,小写。
|
||||
func normalizeDomain(raw string) string {
|
||||
d := strings.ToLower(strings.TrimSpace(raw))
|
||||
if i := strings.Index(d, "://"); i >= 0 {
|
||||
d = d[i+3:]
|
||||
}
|
||||
if i := strings.IndexAny(d, "/?#"); i >= 0 {
|
||||
d = d[:i]
|
||||
}
|
||||
if i := strings.Index(d, ":"); i >= 0 {
|
||||
d = d[:i]
|
||||
}
|
||||
return strings.TrimSuffix(d, ".")
|
||||
}
|
||||
|
||||
func licenseJSON(c *fun.RouteCtx, code int, msg string, extra map[string]any) error {
|
||||
obj := map[string]any{"code": code, "msg": msg}
|
||||
for k, v := range extra {
|
||||
obj[k] = v
|
||||
}
|
||||
return epayJSON(c, obj)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// memory.go 共享记忆库管理(仅管理员):项目分组、搜索、读写删除、MCP 接入信息。
|
||||
// 对外协议端点见 memory_routes.go(POST /mcp + GET /m/<key>)。
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/dto"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/memx"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
)
|
||||
|
||||
// MemorySvc 记忆库管理(仅管理员)。
|
||||
type MemorySvc struct {
|
||||
fun.Ctx
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
func memoryMeta(m store.Memory) dto.MemoryMetaView {
|
||||
return dto.MemoryMetaView{
|
||||
Key: m.Key, Project: m.Project, Tags: m.Tags,
|
||||
WrittenBy: m.WrittenBy, Revision: m.Revision, UpdatedAt: m.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MemorySvc) Projects() (dto.MemoryProjectsView, error) {
|
||||
rows, err := s.Store.MemoryProjects()
|
||||
if err != nil {
|
||||
return dto.MemoryProjectsView{}, err
|
||||
}
|
||||
items := make([]dto.MemoryProjectView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, dto.MemoryProjectView{Name: r.Name, Count: r.Count})
|
||||
}
|
||||
return dto.MemoryProjectsView{Items: items}, nil
|
||||
}
|
||||
|
||||
// List query 非空走全文 AND 搜索;否则按 project/tag 过滤列元数据
|
||||
func (s *MemorySvc) List(d dto.MemoryListDto) (dto.MemoryListView, error) {
|
||||
var ms []store.Memory
|
||||
var err error
|
||||
if q := strings.TrimSpace(d.Query); q != "" {
|
||||
ms, err = s.Store.SearchMemories(q, strings.TrimSpace(d.Project), d.Limit)
|
||||
} else {
|
||||
ms, err = s.Store.ListMemories(strings.TrimSpace(d.Project), strings.TrimSpace(d.Tag), d.Limit)
|
||||
}
|
||||
if err != nil {
|
||||
return dto.MemoryListView{}, err
|
||||
}
|
||||
items := make([]dto.MemoryMetaView, 0, len(ms))
|
||||
for _, m := range ms {
|
||||
items = append(items, memoryMeta(m))
|
||||
}
|
||||
return dto.MemoryListView{Items: items}, nil
|
||||
}
|
||||
|
||||
func (s *MemorySvc) Get(d dto.MemoryKeyDto) (dto.MemoryView, error) {
|
||||
m, err := s.Store.GetMemory(strings.TrimSpace(d.Key))
|
||||
if err != nil {
|
||||
return dto.MemoryView{}, err
|
||||
}
|
||||
if m == nil {
|
||||
return dto.MemoryView{}, fun.Error(4004, "记忆不存在: "+d.Key)
|
||||
}
|
||||
readURL, _ := s.Store.SignMemoryReadURL(requestBase(s.Ctx.RequestCtx), m.Key)
|
||||
return dto.MemoryView{
|
||||
Key: m.Key, Url: readURL, Content: m.Content, Project: m.Project, Tags: m.Tags,
|
||||
WrittenBy: m.WrittenBy, Revision: m.Revision,
|
||||
CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *MemorySvc) Save(d dto.MemorySaveDto) (dto.MemorySaveView, error) {
|
||||
if err := memx.LintContent(d.Content); err != nil {
|
||||
return dto.MemorySaveView{}, fun.Error(4002, err.Error())
|
||||
}
|
||||
project := strings.TrimSpace(d.Project)
|
||||
key := strings.TrimSpace(d.Key)
|
||||
if key == "" {
|
||||
return dto.MemorySaveView{}, fun.Error(4001, "key 必填,形如 project/topic")
|
||||
}
|
||||
m := &store.Memory{
|
||||
Key: key, Content: d.Content, Project: project,
|
||||
Tags: d.Tags, WrittenBy: "admin",
|
||||
}
|
||||
if err := s.Store.SaveMemory(m); err != nil {
|
||||
return dto.MemorySaveView{}, err
|
||||
}
|
||||
readURL, _ := s.Store.SignMemoryReadURL(requestBase(s.Ctx.RequestCtx), m.Key)
|
||||
return dto.MemorySaveView{
|
||||
Key: m.Key, Revision: m.Revision, Url: readURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *MemorySvc) Delete(d dto.MemoryKeyDto) (dto.OkView, error) {
|
||||
if _, err := s.Store.DeleteMemory(strings.TrimSpace(d.Key)); err != nil {
|
||||
return dto.OkView{}, err
|
||||
}
|
||||
return dto.OkView{Message: "已删除"}, nil
|
||||
}
|
||||
|
||||
// McpInfo 返回对外 MCP 端点与 Bearer 令牌(缺省懒生成一个)
|
||||
func (s *MemorySvc) McpInfo() (dto.MemoryMcpView, error) {
|
||||
tok, err := s.Store.McpToken()
|
||||
if err != nil {
|
||||
return dto.MemoryMcpView{}, err
|
||||
}
|
||||
return dto.MemoryMcpView{
|
||||
Url: requestBase(s.Ctx.RequestCtx) + "/mcp",
|
||||
Token: tok,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *MemorySvc) ResetToken() (dto.MemoryMcpView, error) {
|
||||
if _, err := s.Store.ResetMcpToken(); err != nil {
|
||||
return dto.MemoryMcpView{}, err
|
||||
}
|
||||
return s.McpInfo()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// memory_routes.go 记忆库对外协议端点:
|
||||
// POST /mcp Streamable HTTP MCP(Bearer 令牌鉴权,令牌见 MemorySvc.McpInfo)
|
||||
// GET /m 阅读链接索引(Bearer 鉴权——key 可枚举,不能裸奔)
|
||||
// GET /m/* 记忆阅读链接(?sig= 签名鉴权,HMAC(key, 令牌) 签发,令牌重置即吊销)
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/valyala/fasthttp/fasthttpadaptor"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/memx"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
)
|
||||
|
||||
// requestBase 按当前请求 Host + X-Forwarded-Proto 推导站点基址,
|
||||
// 反代换域名后生成的 /m/ 链接与 MCP 接入地址始终可点。
|
||||
func requestBase(ctx *fasthttp.RequestCtx) string {
|
||||
proto := string(ctx.Request.Header.Peek("X-Forwarded-Proto"))
|
||||
if proto == "" {
|
||||
proto = "http"
|
||||
}
|
||||
return proto + "://" + string(ctx.Host())
|
||||
}
|
||||
|
||||
// McpHandler POST /mcp:Authorization Bearer 校验后交给 Streamable HTTP 服务。
|
||||
// 令牌存 settings(mcp.token),首次访问懒生成。
|
||||
func McpHandler(st *store.Store) fun.RouteHandler {
|
||||
mcpSrv := server.NewStreamableHTTPServer(
|
||||
memx.NewMCPServer(st),
|
||||
server.WithStateLess(true),
|
||||
server.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context {
|
||||
proto := r.Header.Get("X-Forwarded-Proto")
|
||||
if proto == "" {
|
||||
proto = "http"
|
||||
if r.TLS != nil {
|
||||
proto = "https"
|
||||
}
|
||||
}
|
||||
return memx.WithBase(ctx, proto+"://"+r.Host)
|
||||
}),
|
||||
)
|
||||
h := fasthttpadaptor.NewFastHTTPHandler(mcpSrv)
|
||||
return func(c *fun.RouteCtx) error {
|
||||
tok, err := st.McpToken()
|
||||
if err != nil {
|
||||
c.RequestCtx.SetStatusCode(500)
|
||||
c.RequestCtx.WriteString(err.Error())
|
||||
return nil
|
||||
}
|
||||
auth := string(c.RequestCtx.Request.Header.Peek("Authorization"))
|
||||
if subtle.ConstantTimeCompare([]byte(auth), []byte("Bearer "+tok)) != 1 {
|
||||
c.RequestCtx.SetStatusCode(401)
|
||||
c.RequestCtx.WriteString("unauthorized")
|
||||
return nil
|
||||
}
|
||||
h(c.RequestCtx)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MemoryReadHandler GET /m/*:输出 markdown 原文;裸 /m/ 列出全部阅读链接。
|
||||
func MemoryReadHandler(st *store.Store) fun.RouteHandler {
|
||||
return func(c *fun.RouteCtx) error {
|
||||
key := c.Wildcard
|
||||
base := requestBase(c.RequestCtx)
|
||||
if key == "" {
|
||||
// 索引收进 Bearer 鉴权:key 形如 project/topic 可枚举,裸索引等于全库暴露
|
||||
tok, err := st.McpToken()
|
||||
if err != nil {
|
||||
c.RequestCtx.SetStatusCode(500)
|
||||
c.RequestCtx.WriteString(err.Error())
|
||||
return nil
|
||||
}
|
||||
auth := string(c.RequestCtx.Request.Header.Peek("Authorization"))
|
||||
if subtle.ConstantTimeCompare([]byte(auth), []byte("Bearer "+tok)) != 1 {
|
||||
c.RequestCtx.SetStatusCode(401)
|
||||
c.RequestCtx.WriteString("unauthorized")
|
||||
return nil
|
||||
}
|
||||
ms, err := st.ListMemories("", "", 200)
|
||||
if err != nil {
|
||||
c.RequestCtx.SetStatusCode(500)
|
||||
c.RequestCtx.WriteString(err.Error())
|
||||
return nil
|
||||
}
|
||||
c.RequestCtx.SetContentType("text/plain; charset=utf-8")
|
||||
for _, m := range ms {
|
||||
readURL, _ := st.SignMemoryReadURL(base, m.Key)
|
||||
line := readURL
|
||||
if m.Project != "" {
|
||||
line += "\t[" + m.Project + "]"
|
||||
}
|
||||
c.RequestCtx.WriteString(line + "\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !st.CheckMemoryReadSig(key, string(c.RequestCtx.QueryArgs().Peek("sig"))) {
|
||||
c.RequestCtx.SetStatusCode(403)
|
||||
c.RequestCtx.WriteString("bad or missing sig")
|
||||
return nil
|
||||
}
|
||||
m, err := st.GetMemory(key)
|
||||
if err != nil {
|
||||
c.RequestCtx.SetStatusCode(500)
|
||||
c.RequestCtx.WriteString(err.Error())
|
||||
return nil
|
||||
}
|
||||
if m == nil {
|
||||
c.RequestCtx.SetStatusCode(404)
|
||||
c.RequestCtx.WriteString("not found")
|
||||
return nil
|
||||
}
|
||||
c.RequestCtx.SetContentType("text/markdown; charset=utf-8")
|
||||
c.RequestCtx.WriteString(m.Content)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,867 @@
|
||||
// pay.go 支付核心:上游 catfk 账号托管、分类/商品自检、卡密库存保障(全程带锁)、
|
||||
// 订单 RPC、支付轮询与商户异步通知。易支付协议端点在 pay_routes.go。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/config"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/db"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/dto"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/payx"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/vault"
|
||||
)
|
||||
|
||||
// ---- 常量与 settings 键 ----
|
||||
|
||||
const (
|
||||
payStatusPending = int64(0)
|
||||
payStatusPaid = int64(1)
|
||||
payStatusClosed = int64(3)
|
||||
|
||||
payUnitFen = int64(1) // 上游商品单价 0.01 元 = 1 分
|
||||
payTargetFen = int64(100000) // 库存目标 1000 元 = 100000 分 = 10w 张卡
|
||||
payCatName = "CDK"
|
||||
payGoodsName = "CDK0.01元"
|
||||
payMaxFen = int64(200000) // 单笔上限 2000 元
|
||||
)
|
||||
|
||||
const (
|
||||
keyPayPid = "pay_pid"
|
||||
keyPayKey = "pay_key"
|
||||
keyUpUsername = "up_username"
|
||||
keyUpPassword = "up_password" // AES 加密存储
|
||||
keyUpNickname = "up_nickname"
|
||||
keyUpShop = "up_shop"
|
||||
keyUpGoodsID = "up_goods_id"
|
||||
keyUpGoodsKey = "up_goods_key"
|
||||
keyUpGoodsName = "up_goods_name"
|
||||
keyUpLastErr = "up_last_error"
|
||||
)
|
||||
|
||||
// ---- 易支付签名与金额工具 ----
|
||||
|
||||
// epaySign 标准 MD5 签名:参数名 ASCII 升序拼 k=v&…(忽略 sign/sign_type 与空值),末尾直接拼接商户密钥。
|
||||
func epaySign(params map[string]string, key string) string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k, v := range params {
|
||||
if k == "sign" || k == "sign_type" || v == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var b strings.Builder
|
||||
for _, k := range keys {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte('&')
|
||||
}
|
||||
b.WriteString(k)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(params[k])
|
||||
}
|
||||
b.WriteString(key)
|
||||
sum := md5.Sum([]byte(b.String()))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func epayVerify(params map[string]string, key, sign string) bool {
|
||||
if params["sign_type"] != "MD5" {
|
||||
return false
|
||||
}
|
||||
return sign != "" && epaySign(params, key) == sign
|
||||
}
|
||||
|
||||
// fenToYuan 分转元字符串,如 199 -> "1.99"。
|
||||
func fenToYuan(fen int64) string {
|
||||
sign := ""
|
||||
if fen < 0 {
|
||||
sign, fen = "-", -fen
|
||||
}
|
||||
return fmt.Sprintf("%s%d.%02d", sign, fen/100, fen%100)
|
||||
}
|
||||
|
||||
// yuanToFen 元字符串转分(最多两位小数)。
|
||||
func yuanToFen(s string) (int64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("金额不能为空")
|
||||
}
|
||||
if strings.HasPrefix(s, "-") {
|
||||
return 0, fmt.Errorf("金额不能为负")
|
||||
}
|
||||
parts := strings.SplitN(s, ".", 2)
|
||||
whole, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("金额格式错误")
|
||||
}
|
||||
fen := whole * 100
|
||||
if len(parts) == 2 {
|
||||
frac := parts[1]
|
||||
if len(frac) > 2 {
|
||||
frac = frac[:2]
|
||||
}
|
||||
frac += strings.Repeat("0", 2-len(frac))
|
||||
f, err := strconv.ParseInt(frac, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("金额格式错误")
|
||||
}
|
||||
fen += f
|
||||
}
|
||||
return fen, nil
|
||||
}
|
||||
|
||||
// genTradeNo 平台订单号:P + 时间戳 + 16 位随机 hex(不可枚举)。
|
||||
func genTradeNo() string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return "P" + strconv.FormatInt(time.Now().Unix(), 10) + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// ---- 商户凭证 ----
|
||||
|
||||
func payMerchant(st *store.Store) (pid, key string, err error) {
|
||||
if pid, err = st.GetSetting(keyPayPid); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if key, err = st.GetSetting(keyPayKey); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return pid, key, nil
|
||||
}
|
||||
|
||||
func strSetting(st *store.Store, key string) string {
|
||||
v, _ := st.GetSetting(key)
|
||||
return v
|
||||
}
|
||||
|
||||
func intSetting(st *store.Store, key string) int64 {
|
||||
v, _ := strconv.ParseInt(strSetting(st, key), 10, 64)
|
||||
return v
|
||||
}
|
||||
|
||||
func anyI64(v any) int64 {
|
||||
switch t := v.(type) {
|
||||
case int64:
|
||||
return t
|
||||
case float64:
|
||||
return int64(t)
|
||||
case []byte:
|
||||
n, _ := strconv.ParseInt(string(t), 10, 64)
|
||||
return n
|
||||
case string:
|
||||
n, _ := strconv.ParseInt(t, 10, 64)
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---- PaySvc 管理端 RPC ----
|
||||
|
||||
type PaySvc struct {
|
||||
fun.Ctx
|
||||
Store *store.Store
|
||||
Up *payx.Client
|
||||
Cfg *config.Config
|
||||
}
|
||||
|
||||
// List 订单分页。
|
||||
func (s *PaySvc) List(d dto.PayOrderListDto) (dto.PayOrderPageView, error) {
|
||||
page, size := d.Page, d.PageSize
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
status := int64(-1)
|
||||
if d.Status != nil {
|
||||
status = *d.Status
|
||||
}
|
||||
kw := sql.NullString{}
|
||||
if d.Kw != nil {
|
||||
kw = sql.NullString{String: strings.TrimSpace(*d.Kw), Valid: true}
|
||||
}
|
||||
ctx := context.Background()
|
||||
arg := db.ListPayOrdersParams{
|
||||
Column1: status, Status: status,
|
||||
Column3: kw.String, Column4: kw, Column5: kw,
|
||||
Limit: size, Offset: (page - 1) * size,
|
||||
}
|
||||
rows, err := s.Store.Q().ListPayOrders(ctx, arg)
|
||||
if err != nil {
|
||||
return dto.PayOrderPageView{}, fun.Error(5000, "查询订单失败")
|
||||
}
|
||||
total, err := s.Store.Q().CountPayOrders(ctx, db.CountPayOrdersParams{
|
||||
Column1: arg.Column1, Status: arg.Status,
|
||||
Column3: arg.Column3, Column4: arg.Column4, Column5: arg.Column5,
|
||||
})
|
||||
if err != nil {
|
||||
return dto.PayOrderPageView{}, fun.Error(5000, "统计订单失败")
|
||||
}
|
||||
items := make([]dto.PayOrderView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, toPayOrderView(r))
|
||||
}
|
||||
return dto.PayOrderPageView{Total: total, Items: items}, nil
|
||||
}
|
||||
|
||||
// Stats 今日与累计统计。
|
||||
func (s *PaySvc) Stats() (dto.PayStatsView, error) {
|
||||
now := time.Now()
|
||||
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Unix()
|
||||
ctx := context.Background()
|
||||
today, err := s.Store.Q().PayOrderStats(ctx, dayStart)
|
||||
if err != nil {
|
||||
return dto.PayStatsView{}, fun.Error(5000, "统计失败")
|
||||
}
|
||||
total, err := s.Store.Q().PayOrderStats(ctx, 0)
|
||||
if err != nil {
|
||||
return dto.PayStatsView{}, fun.Error(5000, "统计失败")
|
||||
}
|
||||
return dto.PayStatsView{
|
||||
TodayCount: today.Cnt, TodayMoney: anyI64(today.Money), TodayPaidCount: anyI64(today.Paid),
|
||||
TotalCount: total.Cnt, TotalMoney: anyI64(total.Money), PendingCount: anyI64(total.Pending),
|
||||
TotalPaidCount: anyI64(total.Paid),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpstreamStatus 上游账号与商品状态(管理页展示)。
|
||||
func (s *PaySvc) UpstreamStatus() (dto.PayUpstreamStatusView, error) {
|
||||
v := dto.PayUpstreamStatusView{
|
||||
Username: strSetting(s.Store, keyUpUsername),
|
||||
Nickname: strSetting(s.Store, keyUpNickname),
|
||||
Shop: strSetting(s.Store, keyUpShop),
|
||||
GoodsName: strSetting(s.Store, keyUpGoodsName),
|
||||
LastError: strSetting(s.Store, keyUpLastErr),
|
||||
TokenAge: s.Up.TokenAge(),
|
||||
GoodsId: intSetting(s.Store, keyUpGoodsID),
|
||||
GoodsKey: strSetting(s.Store, keyUpGoodsKey),
|
||||
UnitPrice: payUnitFen,
|
||||
TargetMoney: payTargetFen,
|
||||
}
|
||||
if payEnsuring.Load() {
|
||||
v.Ensuring = 1
|
||||
}
|
||||
if v.Username != "" {
|
||||
v.Configured = 1
|
||||
}
|
||||
if v.GoodsId > 0 && v.Configured == 1 {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if _, _, stock, err := s.Up.GoodsInfo(ctx, v.GoodsId); err == nil {
|
||||
v.StockCount = stock
|
||||
}
|
||||
if avail, frozen, err := s.Up.WalletInfo(ctx); err == nil {
|
||||
v.WalletOk, v.WalletAvail, v.WalletFrozen = 1, avail, frozen
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// SaveUpstream 保存上游账号并登录验证;成功后异步触发分类/商品自检与库存补齐。
|
||||
func (s *PaySvc) SaveUpstream(d dto.PayUpstreamDto) (dto.PayUpstreamStatusView, error) {
|
||||
username := strings.TrimSpace(d.Username)
|
||||
if username == "" {
|
||||
return dto.PayUpstreamStatusView{}, fun.Error(4001, "上游账号不能为空")
|
||||
}
|
||||
password := d.Password
|
||||
if password == "" {
|
||||
u, p, err := s.decryptUpPassword()
|
||||
if err != nil || u != username || p == "" {
|
||||
return dto.PayUpstreamStatusView{}, fun.Error(4001, "请输入上游密码")
|
||||
}
|
||||
password = p
|
||||
}
|
||||
if err := s.Up.SetCredentials(username, password); err != nil {
|
||||
return dto.PayUpstreamStatusView{}, fun.Error(4002, "上游登录失败:"+err.Error())
|
||||
}
|
||||
nickname, shop, err := s.Up.Userinfo(context.Background())
|
||||
if err != nil {
|
||||
return dto.PayUpstreamStatusView{}, fun.Error(5000, "获取商户信息失败:"+err.Error())
|
||||
}
|
||||
enc, err := vault.Encrypt(s.Cfg.VaultKey, password)
|
||||
if err != nil {
|
||||
return dto.PayUpstreamStatusView{}, fun.Error(5000, "凭据加密失败")
|
||||
}
|
||||
old := strSetting(s.Store, keyUpUsername)
|
||||
sets := map[string]string{
|
||||
keyUpUsername: username, keyUpPassword: enc,
|
||||
keyUpNickname: nickname, keyUpShop: shop,
|
||||
}
|
||||
if old != "" && old != username {
|
||||
sets[keyUpGoodsID], sets[keyUpGoodsKey], sets[keyUpGoodsName] = "", "", ""
|
||||
}
|
||||
for k, v := range sets {
|
||||
if err := s.Store.SetSetting(k, v); err != nil {
|
||||
return dto.PayUpstreamStatusView{}, fun.Error(5000, "保存配置失败")
|
||||
}
|
||||
}
|
||||
ensureGoodsAsync(s.Store, s.Up)
|
||||
return s.UpstreamStatus()
|
||||
}
|
||||
|
||||
// TestPay 创建一笔真实订单走完整上游流程(收银台二维码 + 轮询结算)。
|
||||
func (s *PaySvc) TestPay(d dto.PayTestPayDto) (dto.PayTestPayView, error) {
|
||||
money, err := yuanToFen(d.Money)
|
||||
if err != nil || money <= 0 || money > payMaxFen {
|
||||
return dto.PayTestPayView{}, fun.Error(4001, "金额非法(0-2000 元)")
|
||||
}
|
||||
order, err := createPayOrder(s.Store, s.Up, createPayOrderIn{
|
||||
Channel: "alipay", Subject: "测试支付", Money: money,
|
||||
})
|
||||
if err != nil {
|
||||
return dto.PayTestPayView{}, fun.Error(5000, err.Error())
|
||||
}
|
||||
return dto.PayTestPayView{
|
||||
TradeNo: order.TradeNo, Payurl: order.Payurl, Qrcode: order.Qrcode,
|
||||
Money: order.Money, Cashier: "/cashier/" + order.TradeNo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CashierInfo 收银台公开信息(trade_no 不可枚举即凭证)。
|
||||
func (s *PaySvc) CashierInfo(d dto.PayCashierDto) (dto.PayCashierView, error) {
|
||||
o, err := s.Store.Q().GetPayOrderByTradeNo(context.Background(), strings.TrimSpace(d.TradeNo))
|
||||
if err != nil {
|
||||
return dto.PayCashierView{}, fun.Error(4004, "订单不存在")
|
||||
}
|
||||
lazyCloseExpired(s.Store, &o)
|
||||
return dto.PayCashierView{
|
||||
TradeNo: o.TradeNo, OutTradeNo: o.OutTradeNo, Subject: o.Subject,
|
||||
Money: o.Money, Channel: o.Channel, Status: o.Status,
|
||||
Payurl: o.Payurl, Qrcode: o.Qrcode, Cards: o.Cards,
|
||||
CreatedAt: o.CreatedAt, ExpiredAt: o.ExpiredAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Watch 收银台状态流:服务端推送订单状态变化,终态(已支付/已关闭)自动关闭。
|
||||
func (s *PaySvc) Watch(d dto.PayCashierDto) (*fun.Stream[dto.PayCashierStatusView], error) {
|
||||
tradeNo := strings.TrimSpace(d.TradeNo)
|
||||
o, err := s.Store.Q().GetPayOrderByTradeNo(context.Background(), tradeNo)
|
||||
if err != nil {
|
||||
return nil, fun.Error(4004, "订单不存在")
|
||||
}
|
||||
st := &fun.Stream[dto.PayCashierStatusView]{}
|
||||
go s.watchLoop(st, tradeNo, o)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (s *PaySvc) watchLoop(st *fun.Stream[dto.PayCashierStatusView], tradeNo string, first db.PayOrder) {
|
||||
defer st.Close()
|
||||
last := first.Status
|
||||
push := func(status int64, o db.PayOrder) bool {
|
||||
msg := dto.PayCashierStatusView{Status: status}
|
||||
if status == payStatusPaid && o.ReturnUrl != "" {
|
||||
if pid, key, err := payMerchant(s.Store); err == nil {
|
||||
msg.ReturnUrl = buildSignedURL(o.ReturnUrl, notifyParams(o, pid), key)
|
||||
}
|
||||
}
|
||||
return st.Send(msg) == nil
|
||||
}
|
||||
if !push(last, first) || last != payStatusPending {
|
||||
return
|
||||
}
|
||||
deadline := time.Now().Add(40 * time.Minute)
|
||||
t := time.NewTicker(2 * time.Second)
|
||||
defer t.Stop()
|
||||
for time.Now().Before(deadline) {
|
||||
<-t.C
|
||||
o, err := s.Store.Q().GetPayOrderByTradeNo(context.Background(), tradeNo)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
lazyCloseExpired(s.Store, &o)
|
||||
if o.Status != last {
|
||||
if !push(o.Status, o) {
|
||||
return
|
||||
}
|
||||
last = o.Status
|
||||
if o.Status != payStatusPending {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CashierStatus 收银台轮询:已支付且配置了 return_url 时返回签名回跳地址。
|
||||
func (s *PaySvc) CashierStatus(d dto.PayCashierDto) (dto.PayCashierStatusView, error) {
|
||||
o, err := s.Store.Q().GetPayOrderByTradeNo(context.Background(), strings.TrimSpace(d.TradeNo))
|
||||
if err != nil {
|
||||
return dto.PayCashierStatusView{}, fun.Error(4004, "订单不存在")
|
||||
}
|
||||
res := dto.PayCashierStatusView{Status: o.Status}
|
||||
if o.Status == payStatusPaid && o.ReturnUrl != "" {
|
||||
pid, key, err := payMerchant(s.Store)
|
||||
if err == nil {
|
||||
res.ReturnUrl = buildSignedURL(o.ReturnUrl, notifyParams(o, pid), key)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func toPayOrderView(r db.PayOrder) dto.PayOrderView {
|
||||
return dto.PayOrderView{
|
||||
Id: r.ID, TradeNo: r.TradeNo, OutTradeNo: r.OutTradeNo, Channel: r.Channel,
|
||||
Subject: r.Subject, Money: r.Money, Status: r.Status, Notified: r.Notified,
|
||||
Payurl: r.Payurl, PaidAt: r.PaidAt, ExpiredAt: r.ExpiredAt, CreatedAt: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func lazyCloseExpired(st *store.Store, o *db.PayOrder) {
|
||||
if o.Status == payStatusPending && o.ExpiredAt > 0 && o.ExpiredAt < time.Now().Unix() {
|
||||
_ = st.Q().SetPayOrderStatus(context.Background(), db.SetPayOrderStatusParams{
|
||||
Status: payStatusClosed, PaidAt: o.PaidAt, Cards: o.Cards, ID: o.ID,
|
||||
})
|
||||
o.Status = payStatusClosed
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PaySvc) decryptUpPassword() (string, string, error) {
|
||||
u := strSetting(s.Store, keyUpUsername)
|
||||
enc := strSetting(s.Store, keyUpPassword)
|
||||
if u == "" || enc == "" {
|
||||
return "", "", fmt.Errorf("未保存上游凭据")
|
||||
}
|
||||
p, err := vault.Decrypt(s.Cfg.VaultKey, enc)
|
||||
return u, p, err
|
||||
}
|
||||
|
||||
// ---- 商品自检 + 库存保障 ----
|
||||
|
||||
var payEnsuring atomic.Bool
|
||||
|
||||
// ensureGoodsAsync 后台幂等确保分类「CDK」与商品「CDK0.01元」存在并补齐库存。
|
||||
func ensureGoodsAsync(st *store.Store, up *payx.Client) {
|
||||
if !payEnsuring.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer payEnsuring.Store(false)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
if err := ensureGoods(ctx, st, up); err != nil {
|
||||
_ = st.SetSetting(keyUpLastErr, err.Error())
|
||||
log.Printf("[pay] 上游商品自检失败: %v", err)
|
||||
return
|
||||
}
|
||||
_ = st.SetSetting(keyUpLastErr, "")
|
||||
}()
|
||||
}
|
||||
|
||||
// ensureGoods 幂等自检:分类 CDK → 商品 CDK0.01元(¥0.01)→ 库存补到 1000 元(10w 张,1w/批)。
|
||||
func ensureGoods(ctx context.Context, st *store.Store, up *payx.Client) error {
|
||||
goodsID := intSetting(st, keyUpGoodsID)
|
||||
goodsKey := strSetting(st, keyUpGoodsKey)
|
||||
target := payTargetFen / payUnitFen // 10w 张
|
||||
if goodsID > 0 && goodsKey != "" {
|
||||
return ensureStockTo(ctx, up, goodsID, target)
|
||||
}
|
||||
list, total, err := up.GoodsList(ctx, "", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for page := int64(1); ; page++ {
|
||||
for _, it := range list {
|
||||
if it.Name == payGoodsName && it.Price == payUnitFen && it.Status == 1 {
|
||||
goodsID, goodsKey = it.ID, it.GoodsKey
|
||||
}
|
||||
}
|
||||
if goodsID != 0 || page*20 >= total {
|
||||
break
|
||||
}
|
||||
list, _, err = up.GoodsList(ctx, "", page+1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if goodsID == 0 {
|
||||
var catID int64
|
||||
cats, err := up.CategoryListAll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, cat := range cats {
|
||||
if cat.Name == payCatName {
|
||||
catID = cat.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if catID == 0 {
|
||||
if err := up.CategoryAdd(ctx, payCatName); err != nil {
|
||||
return err
|
||||
}
|
||||
cats, err = up.CategoryListAll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, cat := range cats {
|
||||
if cat.Name == payCatName {
|
||||
catID = cat.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if catID == 0 {
|
||||
return fmt.Errorf("上游分类「%s」创建失败", payCatName)
|
||||
}
|
||||
if goodsID, goodsKey, err = up.GoodsAdd(ctx, payGoodsName, catID, 0.01); err != nil {
|
||||
return err
|
||||
}
|
||||
if goodsID <= 0 || goodsKey == "" {
|
||||
return fmt.Errorf("上游商品创建未返回有效标识")
|
||||
}
|
||||
log.Printf("[pay] 已创建上游商品「%s」id=%d", payGoodsName, goodsID)
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
keyUpGoodsID: strconv.FormatInt(goodsID, 10),
|
||||
keyUpGoodsKey: goodsKey,
|
||||
keyUpGoodsName: payGoodsName,
|
||||
} {
|
||||
if err := st.SetSetting(k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return ensureStockTo(ctx, up, goodsID, target)
|
||||
}
|
||||
|
||||
// ensureStockTo 全程带锁把库存补到 target 张。
|
||||
func ensureStockTo(ctx context.Context, up *payx.Client, goodsID, target int64) error {
|
||||
unlock := up.StockLock()
|
||||
defer unlock()
|
||||
_, err := up.EnsureStock(ctx, goodsID, target)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- 下单 ----
|
||||
|
||||
type createPayOrderIn struct {
|
||||
OutTradeNo string
|
||||
Channel string
|
||||
Subject string
|
||||
Money int64 // 分
|
||||
NotifyURL string
|
||||
ReturnURL string
|
||||
ClientIP string
|
||||
}
|
||||
|
||||
// createPayOrder 本地建单 → 库存保障(带锁)→ 上游下单 → 写回 payurl/二维码。
|
||||
// 幂等:同 out_trade_no 复用未支付订单。
|
||||
func createPayOrder(st *store.Store, up *payx.Client, in createPayOrderIn) (db.PayOrder, error) {
|
||||
now := time.Now().Unix()
|
||||
tradeNo := genTradeNo()
|
||||
outNo := in.OutTradeNo
|
||||
if outNo == "" {
|
||||
outNo = tradeNo // 内部测试单:商户单号=平台单号
|
||||
}
|
||||
if exist, err := st.Q().GetPayOrderByOutNo(context.Background(), outNo); err == nil {
|
||||
switch {
|
||||
case exist.Money != in.Money:
|
||||
return db.PayOrder{}, fmt.Errorf("商户订单号重复且金额不一致")
|
||||
case exist.Status == payStatusPaid:
|
||||
return db.PayOrder{}, fmt.Errorf("该订单已支付")
|
||||
case exist.Status != payStatusPending || (exist.ExpiredAt > 0 && exist.ExpiredAt < now):
|
||||
return db.PayOrder{}, fmt.Errorf("订单已关闭,请更换订单号")
|
||||
default:
|
||||
return exist, nil
|
||||
}
|
||||
}
|
||||
goodsID := intSetting(st, keyUpGoodsID)
|
||||
goodsKey := strSetting(st, keyUpGoodsKey)
|
||||
if goodsID <= 0 || goodsKey == "" {
|
||||
return db.PayOrder{}, fmt.Errorf("上游商品未就绪:请先在支付配置页保存上游账号")
|
||||
}
|
||||
if in.Money%payUnitFen != 0 || in.Money/payUnitFen <= 0 {
|
||||
return db.PayOrder{}, fmt.Errorf("金额必须是 ¥0.01 的整数倍")
|
||||
}
|
||||
quantity := in.Money / payUnitFen
|
||||
id, err := st.Q().CreatePayOrder(context.Background(), db.CreatePayOrderParams{
|
||||
TradeNo: tradeNo, OutTradeNo: outNo, Channel: in.Channel,
|
||||
Subject: in.Subject, Money: in.Money, Fee: 0,
|
||||
NotifyUrl: in.NotifyURL, ReturnUrl: in.ReturnURL, ClientIp: in.ClientIP,
|
||||
ExpiredAt: now + 30*60, CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return db.PayOrder{}, fmt.Errorf("创建订单失败")
|
||||
}
|
||||
o, err := st.Q().GetPayOrder(context.Background(), id)
|
||||
if err != nil {
|
||||
return db.PayOrder{}, fmt.Errorf("创建订单失败")
|
||||
}
|
||||
if err := placeUpstream(st, up, &o, goodsID, goodsKey, quantity); err != nil {
|
||||
if o.UpstreamTradeNo == "" {
|
||||
_ = st.Q().SetPayOrderStatus(context.Background(), db.SetPayOrderStatusParams{
|
||||
Status: payStatusClosed, PaidAt: 0, Cards: "", ID: o.ID,
|
||||
})
|
||||
}
|
||||
return db.PayOrder{}, err
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// placeUpstream 库存保障(全程带锁)→ 上游下单 → 回写。
|
||||
func placeUpstream(st *store.Store, up *payx.Client, o *db.PayOrder, goodsID int64, goodsKey string, quantity int64) error {
|
||||
ctx := context.Background()
|
||||
shop := strSetting(st, keyUpShop)
|
||||
if shop == "" {
|
||||
return fmt.Errorf("上游店铺未配置")
|
||||
}
|
||||
channelID, err := up.AlipayChannelID(ctx, shop)
|
||||
if err != nil {
|
||||
return fmt.Errorf("上游支付通道不可用:%v", err)
|
||||
}
|
||||
contact, queryPwd := payx.RandContact(), payx.RandQueryPwd()
|
||||
|
||||
unlock := up.StockLock()
|
||||
res, err := placeUnderLock(ctx, up, goodsID, goodsKey, quantity, channelID, contact, queryPwd)
|
||||
unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.Payurl = res.PayURL
|
||||
o.UpstreamTradeNo = res.TradeNo
|
||||
if res.TotalAmount != o.Money {
|
||||
log.Printf("[pay] 订单 %s 上游金额 %s 与本地 %s 不一致", o.TradeNo, fenToYuan(res.TotalAmount), fenToYuan(o.Money))
|
||||
}
|
||||
if err := st.Q().SetPayOrderUpstream(ctx, db.SetPayOrderUpstreamParams{
|
||||
UpstreamTradeNo: res.TradeNo, GoodsKey: goodsKey, Quantity: quantity,
|
||||
UnitPrice: payUnitFen, Payurl: res.PayURL, BuyerContact: contact,
|
||||
QueryPwd: queryPwd, ID: o.ID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("回写上游订单失败")
|
||||
}
|
||||
if qr, qrErr := up.FetchQRCode(ctx, res.TradeNo); qrErr == nil && qr != "" {
|
||||
_ = st.Q().SetPayOrderQrcode(ctx, db.SetPayOrderQrcodeParams{Qrcode: qr, ID: o.ID})
|
||||
o.Qrcode = qr
|
||||
} else if qrErr != nil {
|
||||
log.Printf("[pay] 订单 %s 取二维码失败: %v(payurl 仍可用)", o.TradeNo, qrErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// placeUnderLock 持锁执行:库存不足先补齐(补到 max(10w, 当前+需求))再下单。
|
||||
func placeUnderLock(ctx context.Context, up *payx.Client, goodsID int64, goodsKey string, quantity, channelID int64, contact, queryPwd string) (payx.PayOrderResult, error) {
|
||||
target := payTargetFen / payUnitFen
|
||||
_, _, stock, err := up.GoodsInfo(ctx, goodsID)
|
||||
if err != nil {
|
||||
return payx.PayOrderResult{}, fmt.Errorf("上游库存查询失败:%v", err)
|
||||
}
|
||||
if stock < quantity {
|
||||
goal := target
|
||||
if stock+quantity > goal {
|
||||
goal = stock + quantity
|
||||
}
|
||||
log.Printf("[pay] 库存 %d 不足订单需求 %d,补到 %d", stock, quantity, goal)
|
||||
if _, err := up.EnsureStock(ctx, goodsID, goal); err != nil {
|
||||
return payx.PayOrderResult{}, fmt.Errorf("上游库存补充失败:%v", err)
|
||||
}
|
||||
}
|
||||
res, err := up.CreateOrder(ctx, goodsKey, quantity, channelID, contact, queryPwd)
|
||||
if err != nil && strings.Contains(err.Error(), "库存") {
|
||||
if _, serr := up.EnsureStock(ctx, goodsID, target+quantity); serr != nil {
|
||||
return payx.PayOrderResult{}, fmt.Errorf("上游库存补充失败:%v", serr)
|
||||
}
|
||||
res, err = up.CreateOrder(ctx, goodsKey, quantity, channelID, contact, queryPwd)
|
||||
}
|
||||
if err != nil {
|
||||
return payx.PayOrderResult{}, fmt.Errorf("上游下单失败:%v", err)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ---- 支付轮询 ----
|
||||
|
||||
// PayPoller 待支付订单轮询器(fun.Wired 单例,5s 一扫)。
|
||||
type PayPoller struct {
|
||||
Store *store.Store `fun:"auto"`
|
||||
Up *payx.Client `fun:"auto"`
|
||||
Notify *PayNotifier `fun:"auto"`
|
||||
}
|
||||
|
||||
func (p *PayPoller) New() {
|
||||
go p.loop()
|
||||
}
|
||||
|
||||
func (p *PayPoller) loop() {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
p.sweep()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PayPoller) sweep() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
rows, err := p.Store.Q().ListPendingPayOrders(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
for _, o := range rows {
|
||||
if o.ExpiredAt > 0 && o.ExpiredAt < now {
|
||||
_ = p.Store.Q().SetPayOrderStatus(ctx, db.SetPayOrderStatusParams{
|
||||
Status: payStatusClosed, PaidAt: 0, Cards: "", ID: o.ID,
|
||||
})
|
||||
continue
|
||||
}
|
||||
paid, err := p.Up.OrderPaid(ctx, o.UpstreamTradeNo)
|
||||
if err != nil || !paid {
|
||||
continue
|
||||
}
|
||||
cards, _ := p.Up.OrderCards(ctx, o.UpstreamTradeNo)
|
||||
_ = p.Store.Q().SetPayOrderStatus(ctx, db.SetPayOrderStatusParams{
|
||||
Status: payStatusPaid, PaidAt: now, Cards: strings.Join(cards, "\n"), ID: o.ID,
|
||||
})
|
||||
log.Printf("[pay] 订单 %s 已支付", o.TradeNo)
|
||||
p.Notify.OnPaid(o.TradeNo)
|
||||
// 库存被消耗:异步补回目标水位
|
||||
if g := intSetting(p.Store, keyUpGoodsID); g > 0 {
|
||||
ensureStockAsync(p.Store, p.Up, g, payTargetFen/payUnitFen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var stockFilling atomic.Bool
|
||||
|
||||
func ensureStockAsync(st *store.Store, up *payx.Client, goodsID, target int64) {
|
||||
if !stockFilling.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer stockFilling.Store(false)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
if err := ensureStockTo(ctx, up, goodsID, target); err != nil {
|
||||
log.Printf("[pay] 库存回充失败: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ---- 商户异步通知 ----
|
||||
|
||||
// PayNotifier 商户异步通知器(fun.Wired 单例)。
|
||||
type PayNotifier struct {
|
||||
Store *store.Store `fun:"auto"`
|
||||
}
|
||||
|
||||
var notifyBackoff = []time.Duration{0, 15 * time.Second, time.Minute, 5 * time.Minute, 15 * time.Minute}
|
||||
|
||||
// notifyParams 通知/回跳参数(未签名)。
|
||||
func notifyParams(o db.PayOrder, pid string) map[string]string {
|
||||
return map[string]string{
|
||||
"pid": pid,
|
||||
"trade_no": o.TradeNo,
|
||||
"out_trade_no": o.OutTradeNo,
|
||||
"type": o.Channel,
|
||||
"name": o.Subject,
|
||||
"money": fenToYuan(o.Money),
|
||||
"trade_status": "TRADE_SUCCESS",
|
||||
}
|
||||
}
|
||||
|
||||
// buildSignedURL 签名参数拼接到回调地址。
|
||||
func buildSignedURL(base string, params map[string]string, key string) string {
|
||||
u, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return base
|
||||
}
|
||||
q := u.Query()
|
||||
for k, v := range params {
|
||||
q.Set(k, v)
|
||||
}
|
||||
q.Set("sign_type", "MD5")
|
||||
q.Set("sign", epaySign(params, key))
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// OnPaid 异步投递商户 notify_url(退避:立即/15s/1m/5m/15m)。
|
||||
func (n *PayNotifier) OnPaid(tradeNo string) {
|
||||
go n.deliver(tradeNo)
|
||||
}
|
||||
|
||||
func (n *PayNotifier) deliver(tradeNo string) {
|
||||
for i, delay := range notifyBackoff {
|
||||
if delay > 0 {
|
||||
time.Sleep(delay)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
o, err := n.Store.Q().GetPayOrderByTradeNo(ctx, tradeNo)
|
||||
cancel()
|
||||
if err != nil || o.Status != payStatusPaid || o.Notified == 1 || o.NotifyUrl == "" {
|
||||
return
|
||||
}
|
||||
if n.attempt(o) {
|
||||
_ = n.Store.Q().SetPayNotified(context.Background(), o.ID)
|
||||
return
|
||||
}
|
||||
_ = n.Store.Q().IncPayNotifyAttempts(context.Background(), o.ID)
|
||||
log.Printf("[pay] %s 第 %d 次通知未确认", tradeNo, i+1)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *PayNotifier) attempt(o db.PayOrder) bool {
|
||||
pid, key, err := payMerchant(n.Store)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
params := notifyParams(o, pid)
|
||||
params["sign"] = epaySign(params, key)
|
||||
params["sign_type"] = "MD5"
|
||||
form := url.Values{}
|
||||
for k, v := range params {
|
||||
form.Set(k, v)
|
||||
}
|
||||
cli := &http.Client{Timeout: 10 * time.Second}
|
||||
if resp, err := cli.Post(o.NotifyUrl, "application/x-www-form-urlencoded",
|
||||
strings.NewReader(form.Encode())); err == nil && notifyRespOK(resp) {
|
||||
return true
|
||||
}
|
||||
sep := "?"
|
||||
if strings.Contains(o.NotifyUrl, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
if resp, err := cli.Get(o.NotifyUrl + sep + form.Encode()); err == nil {
|
||||
return notifyRespOK(resp)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func notifyRespOK(resp *http.Response) bool {
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
return resp.StatusCode == 200 && strings.Contains(strings.ToLower(string(body)), "success")
|
||||
}
|
||||
|
||||
// BootPay 启动时装配上游凭据(若有)并触发商品自检。
|
||||
func BootPay(st *store.Store, up *payx.Client, cfg *config.Config) {
|
||||
username := strSetting(st, keyUpUsername)
|
||||
enc := strSetting(st, keyUpPassword)
|
||||
if username == "" || enc == "" {
|
||||
return
|
||||
}
|
||||
pass, err := vault.Decrypt(cfg.VaultKey, enc)
|
||||
if err != nil || pass == "" {
|
||||
log.Printf("[pay] 上游凭据解密失败: %v", err)
|
||||
return
|
||||
}
|
||||
up.SetCredentialsLazy(username, pass)
|
||||
ensureGoodsAsync(st, up)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// pay_routes.go 易支付兼容协议端点:/submit.php(跳收银台)、/mapi.php(JSON下单)、/api.php(查单)。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/payx"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
)
|
||||
|
||||
// paySubmitHandler GET/POST /submit.php:验签建单后 302 到收银台。
|
||||
func PaySubmitHandler(st *store.Store, up *payx.Client) fun.RouteHandler {
|
||||
return func(c *fun.RouteCtx) error {
|
||||
order, err := epayCreateOrder(c, st, up)
|
||||
if err != nil {
|
||||
return epayFail(c, err)
|
||||
}
|
||||
// 当码支付:优先跳二维码支付链接(qr.alipay.com),退化到本地收银台
|
||||
target := order.Qrcode
|
||||
if target == "" {
|
||||
target = "/cashier/" + order.TradeNo
|
||||
}
|
||||
c.RequestCtx.Redirect(target, 302)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// payMapiHandler GET/POST /mapi.php:验签建单,JSON 返回 payurl/qrcode。
|
||||
func PayMapiHandler(st *store.Store, up *payx.Client) fun.RouteHandler {
|
||||
return func(c *fun.RouteCtx) error {
|
||||
order, err := epayCreateOrder(c, st, up)
|
||||
if err != nil {
|
||||
return epayFail(c, err)
|
||||
}
|
||||
payurl := order.Qrcode
|
||||
if payurl == "" {
|
||||
payurl = order.Payurl
|
||||
}
|
||||
return epayJSON(c, map[string]any{
|
||||
"code": 1, "msg": "success",
|
||||
"trade_no": order.TradeNo,
|
||||
"out_trade_no": order.OutTradeNo,
|
||||
"type": order.Channel,
|
||||
"name": order.Subject,
|
||||
"money": fenToYuan(order.Money),
|
||||
"payurl": payurl,
|
||||
"qrcode": order.Qrcode,
|
||||
"urlscheme": order.Qrcode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PayApiHandler GET/POST /api.php:act=order 查单(pid+key 验签)。
|
||||
func PayApiHandler(st *store.Store) fun.RouteHandler {
|
||||
return func(c *fun.RouteCtx) error {
|
||||
act := strings.TrimSpace(c.Param("act"))
|
||||
pidStr := strings.TrimSpace(c.Param("pid"))
|
||||
key := strings.TrimSpace(c.Param("key"))
|
||||
merPid, merKey, err := payMerchant(st)
|
||||
if err != nil || pidStr != merPid || key != merKey {
|
||||
return epayJSON(c, map[string]any{"code": -1, "msg": "商户校验失败"})
|
||||
}
|
||||
if act != "order" {
|
||||
return epayJSON(c, map[string]any{"code": -1, "msg": "不支持的 act"})
|
||||
}
|
||||
tradeNo := strings.TrimSpace(c.Param("trade_no"))
|
||||
outNo := strings.TrimSpace(c.Param("out_trade_no"))
|
||||
var order struct {
|
||||
TradeNo string
|
||||
OutTradeNo string
|
||||
Subject string
|
||||
Money int64
|
||||
Status int64
|
||||
}
|
||||
if tradeNo != "" {
|
||||
r, err := st.Q().GetPayOrderByTradeNo(context.Background(), tradeNo)
|
||||
if err != nil {
|
||||
return epayJSON(c, map[string]any{"code": -1, "msg": "订单不存在"})
|
||||
}
|
||||
order.TradeNo, order.OutTradeNo, order.Subject = r.TradeNo, r.OutTradeNo, r.Subject
|
||||
order.Money, order.Status = r.Money, r.Status
|
||||
} else if outNo != "" {
|
||||
r, err := st.Q().GetPayOrderByOutNo(context.Background(), outNo)
|
||||
if err != nil {
|
||||
return epayJSON(c, map[string]any{"code": -1, "msg": "订单不存在"})
|
||||
}
|
||||
order.TradeNo, order.OutTradeNo, order.Subject = r.TradeNo, r.OutTradeNo, r.Subject
|
||||
order.Money, order.Status = r.Money, r.Status
|
||||
} else {
|
||||
return epayJSON(c, map[string]any{"code": -1, "msg": "请传入 out_trade_no 或 trade_no"})
|
||||
}
|
||||
status := int64(0)
|
||||
if order.Status == payStatusPaid {
|
||||
status = 1
|
||||
}
|
||||
return epayJSON(c, map[string]any{
|
||||
"code": 1, "msg": "查询订单号成功",
|
||||
"trade_no": order.TradeNo,
|
||||
"out_trade_no": order.OutTradeNo,
|
||||
"name": order.Subject,
|
||||
"money": fenToYuan(order.Money),
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// epayOrderResult 建单结果(submit/mapi 共用)。
|
||||
type epayOrderResult struct {
|
||||
TradeNo string
|
||||
OutTradeNo string
|
||||
Channel string
|
||||
Subject string
|
||||
Money int64
|
||||
Payurl string
|
||||
Qrcode string
|
||||
}
|
||||
|
||||
// epayCreateOrder submit/mapi 共用下单逻辑:参数校验 → 验签 → 幂等建单。
|
||||
func epayCreateOrder(c *fun.RouteCtx, st *store.Store, up *payx.Client) (epayOrderResult, error) {
|
||||
var out epayOrderResult
|
||||
pid := strings.TrimSpace(c.Param("pid"))
|
||||
merPid, merKey, err := payMerchant(st)
|
||||
if err != nil || merPid == "" {
|
||||
return out, errParam("商户未配置")
|
||||
}
|
||||
if pid == "" || pid != merPid {
|
||||
return out, errParam("PID 参数错误")
|
||||
}
|
||||
channel := strings.TrimSpace(c.Param("type"))
|
||||
if channel == "" {
|
||||
channel = "alipay"
|
||||
}
|
||||
if channel != "alipay" {
|
||||
return out, errParam("当前仅支持支付宝支付(alipay)")
|
||||
}
|
||||
outTradeNo := strings.TrimSpace(c.Param("out_trade_no"))
|
||||
if outTradeNo == "" || len(outTradeNo) > 64 {
|
||||
return out, errParam("商户订单号 out_trade_no 需为 1-64 位")
|
||||
}
|
||||
subject := strings.TrimSpace(c.Param("name"))
|
||||
if subject == "" || len(subject) > 100 {
|
||||
return out, errParam("商品名称 name 需为 1-100 个字符")
|
||||
}
|
||||
money, err := yuanToFen(c.Param("money"))
|
||||
if err != nil || money <= 0 || money > payMaxFen {
|
||||
return out, errParam("订单金额非法(0-2000 元)")
|
||||
}
|
||||
notifyURL := strings.TrimSpace(c.Param("notify_url"))
|
||||
returnURL := strings.TrimSpace(c.Param("return_url"))
|
||||
for _, u := range []string{notifyURL, returnURL} {
|
||||
if u != "" && !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
||||
return out, errParam("notify_url / return_url 须为合法 URL")
|
||||
}
|
||||
}
|
||||
if !epayVerify(c.Data, merKey, strings.TrimSpace(c.Param("sign"))) {
|
||||
return out, errParam("签名验证失败")
|
||||
}
|
||||
order, err := createPayOrder(st, up, createPayOrderIn{
|
||||
OutTradeNo: outTradeNo, Channel: channel, Subject: subject,
|
||||
Money: money, NotifyURL: notifyURL, ReturnURL: returnURL,
|
||||
ClientIP: c.RequestCtx.RemoteIP().String(),
|
||||
})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.TradeNo, out.OutTradeNo, out.Channel = order.TradeNo, order.OutTradeNo, order.Channel
|
||||
out.Subject, out.Money, out.Payurl, out.Qrcode = order.Subject, order.Money, order.Payurl, order.Qrcode
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func epayJSON(c *fun.RouteCtx, obj map[string]any) error {
|
||||
raw, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.RequestCtx.Response.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
c.RequestCtx.Write(raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
func epayFail(c *fun.RouteCtx, err error) error {
|
||||
return epayJSON(c, map[string]any{"code": -1, "msg": err.Error()})
|
||||
}
|
||||
|
||||
func errParam(msg string) error { return fun.Error(4001, msg) }
|
||||
@@ -0,0 +1,416 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/dto"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/proxyx"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
)
|
||||
|
||||
// 代理池后台任务参数
|
||||
const (
|
||||
proxyCheckInterval = time.Hour // 测活节奏:约每小时一轮
|
||||
proxyCheckStale = 55 * time.Minute // 距上次检测超过该时长才重测
|
||||
proxyCheckTimeout = 4 * time.Second // 单次 TCP 拨测超时
|
||||
proxyCheckWorkers = 128 // 测活并发
|
||||
proxyPurgeAge = 7 * 24 * time.Hour // 死代理记录保留时长
|
||||
syncDetailCap = 200 // 同步详情里记录的地址上限
|
||||
)
|
||||
|
||||
var (
|
||||
proxySyncing atomic.Bool
|
||||
proxyChecking atomic.Bool
|
||||
proxyManualSem = make(chan struct{}, 1) // 手动触发同步信号
|
||||
)
|
||||
|
||||
// ProxySvc 代理池:地区分组浏览 + 同步记录;抓取/测活由后台调度器跑
|
||||
type ProxySvc struct {
|
||||
fun.Ctx
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
func proxyView(p store.Proxy) dto.ProxyView {
|
||||
return dto.ProxyView{
|
||||
Id: p.Id, Ip: p.Ip, Port: p.Port, Address: fmt.Sprintf("%s:%d", p.Ip, p.Port),
|
||||
Protocols: p.Protocols, Anonymity: p.Anonymity, RegionId: p.RegionID,
|
||||
RegionCode: p.RegionCode, RegionName: p.RegionName, RegionZhName: p.RegionZhName,
|
||||
Latency: p.Latency, Speed: p.Speed, Uptime: p.Uptime, Alive: p.Alive,
|
||||
FailCount: p.FailCount, LastCheckedAt: p.LastCheckedAt,
|
||||
LastAliveAt: p.LastAliveAt, LastSeenAt: p.LastSeenAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ProxySvc) List(d dto.ProxyListDto) (dto.ProxyListView, error) {
|
||||
alive := d.Alive
|
||||
if alive != 0 && alive != 1 {
|
||||
alive = -1
|
||||
}
|
||||
items, total, err := s.Store.ListProxies(store.ProxyFilter{
|
||||
RegionID: d.RegionId, Protocol: strings.ToUpper(strings.TrimSpace(d.Protocol)),
|
||||
Keyword: strings.TrimSpace(d.Keyword), Alive: alive,
|
||||
Page: d.Page, PageSize: d.PageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return dto.ProxyListView{}, err
|
||||
}
|
||||
out := dto.ProxyListView{Total: total, Items: make([]dto.ProxyView, 0, len(items))}
|
||||
for _, p := range items {
|
||||
out.Items = append(out.Items, proxyView(p))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ProxySvc) Regions() ([]dto.ProxyRegionView, error) {
|
||||
regions, err := s.Store.ListRegions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dto.ProxyRegionView, 0, len(regions))
|
||||
for _, r := range regions {
|
||||
out = append(out, dto.ProxyRegionView{
|
||||
Id: r.Id, Code: r.Code, Name: r.Name, ZhName: r.ZhName,
|
||||
Region: r.Region, AliveCount: r.AliveCount,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ProxySvc) Syncs() ([]dto.ProxySyncView, error) {
|
||||
syncs, err := s.Store.ListProxySyncs(30)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dto.ProxySyncView, 0, len(syncs))
|
||||
for _, v := range syncs {
|
||||
out = append(out, dto.ProxySyncView{
|
||||
Id: v.Id, StartedAt: v.StartedAt, FinishedAt: v.FinishedAt, Status: v.Status,
|
||||
Source: v.Source, Total: v.Total, Added: v.Added, Removed: v.Removed,
|
||||
Updated: v.Updated, DetailJson: v.Detail, Error: v.Error,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ProxySvc) Stats() (dto.ProxyStatsView, error) {
|
||||
total, err := s.Store.CountAliveProxies()
|
||||
if err != nil {
|
||||
return dto.ProxyStatsView{}, err
|
||||
}
|
||||
regions, err := s.Store.ListRegions()
|
||||
if err != nil {
|
||||
return dto.ProxyStatsView{}, err
|
||||
}
|
||||
v := dto.ProxyStatsView{
|
||||
Total: total, Regions: int64(len(regions)),
|
||||
Syncing: proxySyncing.Load(), Checking: proxyChecking.Load(),
|
||||
LastCheckAt: s.Store.LastProxyCheckAt(),
|
||||
}
|
||||
if syncs, err := s.Store.ListProxySyncs(1); err == nil && len(syncs) > 0 {
|
||||
v.LastSyncAt = syncs[0].FinishedAt
|
||||
v.LastStatus = syncs[0].Status
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// SyncNow 手动触发一次同步(后台执行,不等结果)
|
||||
func (s *ProxySvc) SyncNow() (dto.OkView, error) {
|
||||
select {
|
||||
case proxyManualSem <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return dto.OkView{Message: "ok"}, nil
|
||||
}
|
||||
|
||||
// CheckNow 手动触发一轮测活(后台执行)
|
||||
func (s *ProxySvc) CheckNow() (dto.OkView, error) {
|
||||
go runProxyCheck(context.Background(), s.Store)
|
||||
return dto.OkView{Message: "ok"}, nil
|
||||
}
|
||||
|
||||
// ---- 同步与测活 ----
|
||||
|
||||
type syncDetail struct {
|
||||
Added []string `json:"added,omitempty"`
|
||||
Removed []string `json:"removed,omitempty"`
|
||||
}
|
||||
|
||||
// runProxySync 全量同步:拉取源数据 → 区域 upsert → 代理按 ip:port diff。
|
||||
// 源上消失的代理直接删除;已存在的刷新指标并复活。
|
||||
func runProxySync(ctx context.Context, st *store.Store) {
|
||||
if !proxySyncing.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
defer proxySyncing.Store(false)
|
||||
|
||||
now := time.Now()
|
||||
sync := &store.ProxySync{Source: "proxy5", StartedAt: now.Unix()}
|
||||
syncID, err := st.BeginProxySync(sync.Source, sync.StartedAt)
|
||||
if err != nil {
|
||||
log.Printf("proxy: begin sync failed: %v", err)
|
||||
return
|
||||
}
|
||||
sync.Id = syncID
|
||||
finish := func(status string, detail syncDetail, err error) {
|
||||
sync.FinishedAt = time.Now().Unix()
|
||||
sync.Status = status
|
||||
if b, e := json.Marshal(detail); e == nil {
|
||||
sync.Detail = string(b)
|
||||
}
|
||||
if err != nil {
|
||||
sync.Error = err.Error()
|
||||
}
|
||||
if e := st.FinishProxySync(sync); e != nil {
|
||||
log.Printf("proxy: finish sync failed: %v", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 多源抓取:任一源失败不致命,只影响该源名下代理的删除判定
|
||||
type srcData struct {
|
||||
name string
|
||||
remotes []proxyx.Remote
|
||||
}
|
||||
sources := []srcData{}
|
||||
failed := map[string]error{}
|
||||
if p5, _, err := proxyx.Fetch(ctx); err != nil {
|
||||
failed["proxy5"] = err
|
||||
} else {
|
||||
sources = append(sources, srcData{"proxy5", p5})
|
||||
}
|
||||
if rl, err := proxyx.FetchRola(ctx); err != nil {
|
||||
failed["rola"] = err
|
||||
} else {
|
||||
sources = append(sources, srcData{"rola", rl})
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
err := fmt.Errorf("all sources failed: proxy5=%v rola=%v", failed["proxy5"], failed["rola"])
|
||||
finish("error", syncDetail{}, err)
|
||||
log.Printf("proxy: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 按 ip:port 合并各源记录:首个来源保留字段,协议取并集
|
||||
type merged struct {
|
||||
r proxyx.Remote
|
||||
source string
|
||||
}
|
||||
pool := map[string]*merged{}
|
||||
order := []string{}
|
||||
for _, src := range sources {
|
||||
for _, r := range src.remotes {
|
||||
key := fmt.Sprintf("%s:%d", r.IP, r.Port)
|
||||
if m, ok := pool[key]; ok {
|
||||
seen := map[string]bool{}
|
||||
for _, p := range m.r.Protocols {
|
||||
seen[p] = true
|
||||
}
|
||||
for _, p := range r.Protocols {
|
||||
if !seen[p] {
|
||||
m.r.Protocols = append(m.r.Protocols, p)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
pool[key] = &merged{r: r, source: src.name}
|
||||
order = append(order, key)
|
||||
}
|
||||
}
|
||||
sync.Total = int64(len(order))
|
||||
seenKeys := map[string]bool{}
|
||||
for k := range pool {
|
||||
seenKeys[k] = true
|
||||
}
|
||||
|
||||
keys, err := st.ProxyKeys()
|
||||
if err != nil {
|
||||
finish("error", syncDetail{}, err)
|
||||
return
|
||||
}
|
||||
|
||||
var detail syncDetail
|
||||
regionIDs := map[string]int64{}
|
||||
for _, key := range order {
|
||||
m := pool[key]
|
||||
r := m.r
|
||||
code := strings.ToUpper(strings.TrimSpace(r.CountryCode))
|
||||
if code == "" {
|
||||
code = "UN"
|
||||
}
|
||||
regionID, ok := regionIDs[code]
|
||||
if !ok {
|
||||
regionID, err = st.UpsertRegion(code, r.Country, proxyx.ZhName(code), r.Region, r.CountrySlug)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
regionIDs[code] = regionID
|
||||
}
|
||||
p := &store.Proxy{
|
||||
Ip: r.IP, Port: int64(r.Port), Protocols: strings.Join(r.Protocols, ","),
|
||||
Anonymity: r.Anonymity, Source: m.source, RegionID: regionID,
|
||||
Latency: int64(r.Latency), Speed: int64(r.Speed), Uptime: int64(r.Uptime),
|
||||
LastSeenAt: now.Unix(), CreatedAt: now.Unix(),
|
||||
}
|
||||
if k, exists := keys[key]; exists {
|
||||
delete(keys, key)
|
||||
if err := st.UpdateProxySeen(k.Id, p); err == nil {
|
||||
sync.Updated++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := st.InsertProxy(p); err == nil {
|
||||
sync.Added++
|
||||
if len(detail.Added) < syncDetailCap {
|
||||
detail.Added = append(detail.Added, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 删除:出现在任一成功源里的都保留;只属于失败源的也保留(可能还在源上)
|
||||
for key, k := range keys {
|
||||
if seenKeys[key] {
|
||||
continue
|
||||
}
|
||||
if _, srcFailed := failed[k.Source]; srcFailed {
|
||||
continue
|
||||
}
|
||||
if err := st.DeleteProxy(k.Id); err == nil {
|
||||
sync.Removed++
|
||||
if len(detail.Removed) < syncDetailCap {
|
||||
detail.Removed = append(detail.Removed, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
var syncErr error
|
||||
if len(failed) > 0 {
|
||||
syncErr = fmt.Errorf("partial: %v", failed)
|
||||
}
|
||||
finish("ok", detail, syncErr)
|
||||
log.Printf("proxy: sync done total=%d added=%d updated=%d removed=%d failed=%v",
|
||||
sync.Total, sync.Added, sync.Updated, sync.Removed, failed)
|
||||
}
|
||||
|
||||
// runProxyCheck 测活一轮:取距上次检测超过阈值的存活代理,并发 TCP 拨测;
|
||||
// 连不上即标记为死(alive=0),死记录超过保留期后物理清除。
|
||||
func runProxyCheck(ctx context.Context, st *store.Store) {
|
||||
if !proxyChecking.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
defer proxyChecking.Store(false)
|
||||
|
||||
due, err := st.DueProxies(time.Now().Add(-proxyCheckStale).Unix())
|
||||
if err != nil {
|
||||
log.Printf("proxy: due list failed: %v", err)
|
||||
return
|
||||
}
|
||||
if len(due) == 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
sem := make(chan struct{}, proxyCheckWorkers)
|
||||
var wg sync.WaitGroup
|
||||
for _, p := range due {
|
||||
wg.Add(1)
|
||||
go func(p store.DueProxy) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
latency, err := proxyx.Check(ctx, p.Ip, int(p.Port), p.Protocols, proxyCheckTimeout)
|
||||
if err != nil {
|
||||
_ = st.MarkProxyFailed(p.Id, now, p.FailCount+1)
|
||||
return
|
||||
}
|
||||
_ = st.MarkProxyAlive(p.Id, now, now, int64(latency))
|
||||
}(p)
|
||||
}
|
||||
wg.Wait()
|
||||
if n, err := st.PurgeDeadProxies(time.Now().Add(-proxyPurgeAge).Unix()); err == nil && n > 0 {
|
||||
log.Printf("proxy: purged %d dead records", n)
|
||||
}
|
||||
log.Printf("proxy: checked %d proxies", len(due))
|
||||
}
|
||||
|
||||
// StartProxyScheduler 调度器:启动补一次同步(数据为空时)→ 每小时测活 → 每日 00:00 同步。
|
||||
func StartProxyScheduler(ctx context.Context, st *store.Store) {
|
||||
if syncs, err := st.ListProxySyncs(1); err == nil {
|
||||
if len(syncs) == 0 || time.Since(time.Unix(syncs[0].StartedAt, 0)) > 20*time.Hour {
|
||||
go runProxySync(ctx, st)
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
checkTick := time.NewTicker(proxyCheckInterval)
|
||||
defer checkTick.Stop()
|
||||
for {
|
||||
next := nextMidnight()
|
||||
syncTimer := time.NewTimer(time.Until(next))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
syncTimer.Stop()
|
||||
return
|
||||
case <-checkTick.C:
|
||||
runProxyCheck(ctx, st)
|
||||
case <-proxyManualSem:
|
||||
go runProxySync(ctx, st)
|
||||
case <-syncTimer.C:
|
||||
runProxySync(ctx, st)
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("proxy: scheduler started (check hourly, sync daily 00:00)")
|
||||
}
|
||||
|
||||
func nextMidnight() time.Time {
|
||||
now := time.Now()
|
||||
return time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
|
||||
}
|
||||
|
||||
// ---- 提取密钥管理(admin) ----
|
||||
|
||||
func proxyKeyView(k store.ProxyApiKey) dto.ProxyKeyView {
|
||||
return dto.ProxyKeyView{
|
||||
Id: k.Id, Key: k.Key, Name: k.Name, RegionId: k.RegionID,
|
||||
RegionCode: k.RegionCode, RegionZhName: k.RegionZhName,
|
||||
Enabled: k.Enabled, UsedCount: k.UsedCount,
|
||||
LastUsedAt: k.LastUsedAt, CreatedAt: k.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ProxySvc) ListKeys() ([]dto.ProxyKeyView, error) {
|
||||
keys, err := s.Store.ListProxyApiKeys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dto.ProxyKeyView, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, proxyKeyView(k))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateKey 生成一个提取密钥:绑定地区(0=全部),key 随机生成落库
|
||||
func (s *ProxySvc) CreateKey(d dto.ProxyKeySaveDto) (dto.ProxyKeyView, error) {
|
||||
key, err := shareCode()
|
||||
if err != nil {
|
||||
return dto.ProxyKeyView{}, err
|
||||
}
|
||||
key = "pk_" + key + key // 20 位随机串,够长防穷举
|
||||
id, err := s.Store.InsertProxyApiKey(key, strings.TrimSpace(d.Name), d.RegionId)
|
||||
if err != nil {
|
||||
return dto.ProxyKeyView{}, err
|
||||
}
|
||||
return dto.ProxyKeyView{Id: id, Key: key, Name: d.Name, RegionId: d.RegionId, Enabled: true}, nil
|
||||
}
|
||||
|
||||
func (s *ProxySvc) DeleteKey(d dto.IdDto) (dto.OkView, error) {
|
||||
if err := s.Store.DeleteProxyApiKey(d.Id); err != nil {
|
||||
return dto.OkView{}, err
|
||||
}
|
||||
return dto.OkView{Message: "ok"}, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/proxyx"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
extractBatchSize = 15 // 每批并行探测的候选数
|
||||
extractBatches = 4 // 最多取几批
|
||||
extractProbeTO = 3 * time.Second // 单代理探测超时
|
||||
extractDeadline = 20 * time.Second // 整个请求的最长耗时
|
||||
)
|
||||
|
||||
type extractResult struct {
|
||||
addr string
|
||||
lat int
|
||||
ok bool
|
||||
}
|
||||
|
||||
// ProxyExtractHandler GET /v1/proxy?key=xxx
|
||||
// 公开提取接口:key 落库校验,地区绑在 key 上(0=全部)。
|
||||
// 并行探测一批候选,谁先探活返回谁,纯文本 "ip:port";两批全死返回 503。
|
||||
func ProxyExtractHandler(st *store.Store) fun.RouteHandler {
|
||||
return func(rc *fun.RouteCtx) error {
|
||||
reqCtx := rc.RequestCtx
|
||||
key := strings.TrimSpace(string(reqCtx.QueryArgs().Peek("key")))
|
||||
if key == "" {
|
||||
routeJSONErr(reqCtx, 401, "missing key")
|
||||
return nil
|
||||
}
|
||||
k, err := st.GetProxyApiKey(key)
|
||||
if err != nil {
|
||||
routeJSONErr(reqCtx, 401, "invalid key")
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(extractDeadline))
|
||||
defer cancel()
|
||||
|
||||
for b := 0; b < extractBatches; b++ {
|
||||
items, err := st.RandomProxies(k.RegionID, "", "", extractBatchSize)
|
||||
if err != nil {
|
||||
routeJSONErr(reqCtx, 500, "query failed")
|
||||
return nil
|
||||
}
|
||||
if len(items) == 0 {
|
||||
break
|
||||
}
|
||||
if p, _, ok := probeBatch(ctx, st, items); ok {
|
||||
st.TouchProxyApiKey(k.Id)
|
||||
reqCtx.SetContentType("text/plain; charset=utf-8")
|
||||
reqCtx.SetBodyString(fmt.Sprintf("%s:%d\n", p.Ip, p.Port))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
routeJSONErr(reqCtx, 503, "no alive proxy")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// probeBatch 并行探测一批候选:先活先赢,取消其余;死代理顺手标记移出池。
|
||||
func probeBatch(ctx context.Context, st *store.Store, items []store.RandomProxy) (store.RandomProxy, int, bool) {
|
||||
type hit struct {
|
||||
p store.RandomProxy
|
||||
lat int
|
||||
}
|
||||
hits := make(chan hit, 1)
|
||||
var wg sync.WaitGroup
|
||||
for _, p := range items {
|
||||
wg.Add(1)
|
||||
go func(p store.RandomProxy) {
|
||||
defer wg.Done()
|
||||
lat, err := proxyx.Check(ctx, p.Ip, int(p.Port), p.Protocols, extractProbeTO)
|
||||
if err != nil {
|
||||
_ = st.MarkProxyFailed(p.Id, time.Now().Unix(), p.FailCount+1)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case hits <- hit{p, lat}:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}(p)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { wg.Wait(); close(done) }()
|
||||
select {
|
||||
case h := <-hits:
|
||||
now := time.Now().Unix()
|
||||
_ = st.MarkProxyAlive(h.p.Id, now, now, int64(h.lat))
|
||||
return h.p, h.lat, true
|
||||
case <-done:
|
||||
return store.RandomProxy{}, 0, false
|
||||
case <-ctx.Done():
|
||||
return store.RandomProxy{}, 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
"github.com/fasthttp/websocket"
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/valyala/fasthttp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/config"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/dto"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/sshx"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/vault"
|
||||
)
|
||||
|
||||
// ServerSvc 服务器管理:连接密钥加密落库,所有 SSH/SFTP 端点仅管理员。
|
||||
// 指标采集与文件操作每次新拨号——管理场景连接低频,免去连接池维护成本。
|
||||
type ServerSvc struct {
|
||||
fun.Ctx
|
||||
Store *store.Store
|
||||
Cfg *config.Config `fun:"auto"`
|
||||
}
|
||||
|
||||
func (s *ServerSvc) List() ([]dto.ServerView, error) {
|
||||
items, err := s.Store.ListServers()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("servers: list failed: %w", err)
|
||||
}
|
||||
views := make([]dto.ServerView, 0, len(items))
|
||||
for _, e := range items {
|
||||
views = append(views, serverView(e))
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
func (s *ServerSvc) Save(d dto.SaveServerDto) (dto.ServerView, error) {
|
||||
name := strings.TrimSpace(d.Name)
|
||||
host := strings.TrimSpace(d.Host)
|
||||
username := strings.TrimSpace(d.Username)
|
||||
if name == "" || len(name) > 64 {
|
||||
return dto.ServerView{}, fun.Error(4001, "名称必填且不超过 64 字符")
|
||||
}
|
||||
if host == "" || len(host) > 255 {
|
||||
return dto.ServerView{}, fun.Error(4001, "主机地址必填")
|
||||
}
|
||||
if username == "" {
|
||||
return dto.ServerView{}, fun.Error(4001, "用户名必填")
|
||||
}
|
||||
authType := "password"
|
||||
if d.AuthType == "key" {
|
||||
authType = "key"
|
||||
}
|
||||
port := int64(22)
|
||||
if d.Port != nil {
|
||||
if *d.Port < 1 || *d.Port > 65535 {
|
||||
return dto.ServerView{}, fun.Error(4001, "端口范围 1-65535")
|
||||
}
|
||||
port = *d.Port
|
||||
}
|
||||
|
||||
e := store.Server{Name: name, Host: host, Port: port, Username: username, AuthType: authType}
|
||||
if d.Id != nil {
|
||||
old, err := s.Store.GetServer(*d.Id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return dto.ServerView{}, fun.Error(4001, "服务器不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return dto.ServerView{}, err
|
||||
}
|
||||
e = old
|
||||
e.Name, e.Host, e.Port, e.Username, e.AuthType = name, host, port, username, authType
|
||||
}
|
||||
if d.Note != nil {
|
||||
e.Note = strings.TrimSpace(*d.Note)
|
||||
}
|
||||
if d.Secret != nil {
|
||||
secret := strings.TrimSpace(*d.Secret)
|
||||
if secret == "" {
|
||||
e.SecretEnc = ""
|
||||
} else {
|
||||
enc, err := vault.Encrypt(s.Cfg.VaultKey, secret)
|
||||
if err != nil {
|
||||
return dto.ServerView{}, err
|
||||
}
|
||||
e.SecretEnc = enc
|
||||
}
|
||||
}
|
||||
if e.SecretEnc == "" {
|
||||
return dto.ServerView{}, fun.Error(4001, "请填写密码或私钥")
|
||||
}
|
||||
if err := s.Store.SaveServer(&e); err != nil {
|
||||
return dto.ServerView{}, fmt.Errorf("servers: save failed: %w", err)
|
||||
}
|
||||
return serverView(e), nil
|
||||
}
|
||||
|
||||
func (s *ServerSvc) Delete(d dto.ServerIdDto) (dto.OkView, error) {
|
||||
if err := s.Store.DeleteServer(d.Id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return dto.OkView{}, fun.Error(4001, "服务器不存在")
|
||||
}
|
||||
return dto.OkView{}, fmt.Errorf("servers: delete failed: %w", err)
|
||||
}
|
||||
return dto.OkView{Message: "已删除"}, nil
|
||||
}
|
||||
|
||||
// Metrics SSH 拨号 + 采集;拨不通直接返回带因的错误,前端即「离线」
|
||||
func (s *ServerSvc) Metrics(d dto.ServerIdDto) (dto.ServerMetricsView, error) {
|
||||
client, err := s.dial(d.Id)
|
||||
if err != nil {
|
||||
return dto.ServerMetricsView{}, err
|
||||
}
|
||||
defer client.Close()
|
||||
m, err := sshx.CollectMetrics(client)
|
||||
if err != nil {
|
||||
return dto.ServerMetricsView{}, err
|
||||
}
|
||||
v := dto.ServerMetricsView{
|
||||
Hostname: m.Hostname,
|
||||
Os: m.OS,
|
||||
Kernel: m.Kernel,
|
||||
UptimeSeconds: m.UptimeSeconds,
|
||||
CpuPercent: m.CpuPercent,
|
||||
Load1: m.Load1,
|
||||
Load5: m.Load5,
|
||||
Load15: m.Load15,
|
||||
MemTotal: m.MemTotal,
|
||||
MemUsed: m.MemUsed,
|
||||
MemAvail: m.MemAvail,
|
||||
SwapTotal: m.SwapTotal,
|
||||
SwapUsed: m.SwapUsed,
|
||||
DiskTotal: m.DiskTotal,
|
||||
DiskUsed: m.DiskUsed,
|
||||
DiskAvail: m.DiskAvail,
|
||||
NetRxRate: m.NetRxRate,
|
||||
NetTxRate: m.NetTxRate,
|
||||
NetRxTotal: m.NetRxTotal,
|
||||
NetTxTotal: m.NetTxTotal,
|
||||
CollectedAt: time.Now().Unix(),
|
||||
}
|
||||
for _, dsk := range m.Disks {
|
||||
v.Disks = append(v.Disks, dto.DiskMountView{
|
||||
Mount: dsk.Mount, Fs: dsk.Fs, Total: dsk.Total,
|
||||
Used: dsk.Used, Avail: dsk.Avail, Percent: dsk.Percent,
|
||||
})
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (s *ServerSvc) dial(id int64) (*ssh.Client, error) {
|
||||
e, err := s.Store.GetServer(id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, fun.Error(4001, "服务器不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dialServer(e, s.Cfg.VaultKey)
|
||||
}
|
||||
|
||||
// dialServer 解密 secret 并拨号;route 处理器与 RPC 共用
|
||||
func dialServer(e store.Server, key []byte) (*ssh.Client, error) {
|
||||
if e.SecretEnc == "" {
|
||||
return nil, fun.Error(4001, "该服务器未配置密码/私钥")
|
||||
}
|
||||
secret, err := vault.Decrypt(key, e.SecretEnc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("servers: decrypt secret failed: %w", err)
|
||||
}
|
||||
return sshx.Dial(e.Host, e.Port, e.Username, e.AuthType, secret)
|
||||
}
|
||||
|
||||
func serverView(e store.Server) dto.ServerView {
|
||||
v := dto.ServerView{
|
||||
Id: e.Id,
|
||||
Name: e.Name,
|
||||
Host: e.Host,
|
||||
Port: e.Port,
|
||||
Username: e.Username,
|
||||
AuthType: e.AuthType,
|
||||
HasSecret: e.SecretEnc != "",
|
||||
CreatedAt: e.CreatedAt,
|
||||
}
|
||||
if e.Note != "" {
|
||||
v.Note = &e.Note
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ---- SFTP RPC ----
|
||||
|
||||
// cleanPath 规整远程路径:缺省 "/",防穿越由 SSH 权限兜底
|
||||
func cleanPath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return "/"
|
||||
}
|
||||
return path.Clean(p)
|
||||
}
|
||||
|
||||
func (s *ServerSvc) sftpClient(id int64) (*sftp.Client, *ssh.Client, error) {
|
||||
c, err := s.dial(id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
fc, err := sftp.NewClient(c)
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return nil, nil, fmt.Errorf("sftp: 初始化失败: %w", err)
|
||||
}
|
||||
return fc, c, nil
|
||||
}
|
||||
|
||||
func (s *ServerSvc) SftpList(d dto.SftpPathDto) (dto.SftpListView, error) {
|
||||
fc, c, err := s.sftpClient(d.Id)
|
||||
if err != nil {
|
||||
return dto.SftpListView{}, err
|
||||
}
|
||||
defer fc.Close()
|
||||
defer c.Close()
|
||||
p := cleanPath(d.Path)
|
||||
fis, err := fc.ReadDir(p)
|
||||
if err != nil {
|
||||
return dto.SftpListView{}, fun.Error(4001, "无法读取目录:"+err.Error())
|
||||
}
|
||||
v := dto.SftpListView{Path: p, Entries: []dto.SftpEntryView{}}
|
||||
for _, fi := range fis {
|
||||
v.Entries = append(v.Entries, dto.SftpEntryView{
|
||||
Name: fi.Name(),
|
||||
Size: fi.Size(),
|
||||
Mode: fi.Mode().String(),
|
||||
IsDir: fi.IsDir(),
|
||||
ModTime: fi.ModTime().Unix(),
|
||||
})
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (s *ServerSvc) SftpMkdir(d dto.SftpPathDto) (dto.OkView, error) {
|
||||
fc, c, err := s.sftpClient(d.Id)
|
||||
if err != nil {
|
||||
return dto.OkView{}, err
|
||||
}
|
||||
defer fc.Close()
|
||||
defer c.Close()
|
||||
if err := fc.Mkdir(cleanPath(d.Path)); err != nil {
|
||||
return dto.OkView{}, fun.Error(4001, "创建目录失败:"+err.Error())
|
||||
}
|
||||
return dto.OkView{Message: "已创建"}, nil
|
||||
}
|
||||
|
||||
// SftpRemove 删文件或空目录;非空目录拒绝递归删除,避免误删整棵目录树
|
||||
func (s *ServerSvc) SftpRemove(d dto.SftpPathDto) (dto.OkView, error) {
|
||||
fc, c, err := s.sftpClient(d.Id)
|
||||
if err != nil {
|
||||
return dto.OkView{}, err
|
||||
}
|
||||
defer fc.Close()
|
||||
defer c.Close()
|
||||
p := cleanPath(d.Path)
|
||||
fi, err := fc.Stat(p)
|
||||
if err != nil {
|
||||
return dto.OkView{}, fun.Error(4001, "路径不存在")
|
||||
}
|
||||
if fi.IsDir() {
|
||||
err = fc.RemoveDirectory(p)
|
||||
} else {
|
||||
err = fc.Remove(p)
|
||||
}
|
||||
if err != nil {
|
||||
return dto.OkView{}, fun.Error(4001, "删除失败:"+err.Error()+"(目录需为空)")
|
||||
}
|
||||
return dto.OkView{Message: "已删除"}, nil
|
||||
}
|
||||
|
||||
func (s *ServerSvc) SftpRename(d dto.SftpRenameDto) (dto.OkView, error) {
|
||||
newName := strings.TrimSpace(d.NewName)
|
||||
if newName == "" || strings.ContainsAny(newName, "/\\") {
|
||||
return dto.OkView{}, fun.Error(4001, "新名字无效")
|
||||
}
|
||||
fc, c, err := s.sftpClient(d.Id)
|
||||
if err != nil {
|
||||
return dto.OkView{}, err
|
||||
}
|
||||
defer fc.Close()
|
||||
defer c.Close()
|
||||
old := cleanPath(d.Path)
|
||||
if err := fc.PosixRename(old, path.Join(path.Dir(old), newName)); err != nil {
|
||||
return dto.OkView{}, fun.Error(4001, "重命名失败:"+err.Error())
|
||||
}
|
||||
return dto.OkView{Message: "已重命名"}, nil
|
||||
}
|
||||
|
||||
// ---- 自定义路由:上传 / 下载 / WS 终端 ----
|
||||
|
||||
// routeAdmin 自定义路由不走 AuthGuard,手动校验 query token 且要求管理员
|
||||
func routeAdmin(st *store.Store, ctx *fasthttp.RequestCtx) error {
|
||||
token := string(ctx.QueryArgs().Peek("token"))
|
||||
if token == "" {
|
||||
ctx.SetStatusCode(fasthttp.StatusUnauthorized)
|
||||
return errors.New("missing token")
|
||||
}
|
||||
su, err := st.GetSessionUser(token, sessionTTL, sessionRenewAfter)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusUnauthorized)
|
||||
return errors.New("invalid token")
|
||||
}
|
||||
if su.User.Role != "admin" || su.User.Status != "active" {
|
||||
ctx.SetStatusCode(fasthttp.StatusForbidden)
|
||||
return errors.New("admin only")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func routeServer(st *store.Store, ctx *fasthttp.RequestCtx) (store.Server, error) {
|
||||
id, _ := strconv.ParseInt(string(ctx.QueryArgs().Peek("id")), 10, 64)
|
||||
e, err := st.GetServer(id)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(fasthttp.StatusNotFound)
|
||||
ctx.SetBodyString("server not found")
|
||||
}
|
||||
return e, err
|
||||
}
|
||||
|
||||
// SftpDownloadHandler GET /files/download?id=&path=&token= 流式回传文件
|
||||
func SftpDownloadHandler(st *store.Store, cfg *config.Config) fun.RouteHandler {
|
||||
return func(rc *fun.RouteCtx) error {
|
||||
ctx := rc.RequestCtx
|
||||
if err := routeAdmin(st, ctx); err != nil {
|
||||
return nil
|
||||
}
|
||||
e, err := routeServer(st, ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
client, err := dialServer(e, cfg.VaultKey)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(502)
|
||||
ctx.SetBodyString("SSH 连接失败:" + err.Error())
|
||||
return nil
|
||||
}
|
||||
fc, err := sftp.NewClient(client)
|
||||
if err != nil {
|
||||
client.Close()
|
||||
ctx.SetStatusCode(502)
|
||||
ctx.SetBodyString("SFTP 初始化失败")
|
||||
return nil
|
||||
}
|
||||
p := cleanPath(string(ctx.QueryArgs().Peek("path")))
|
||||
f, err := fc.Open(p)
|
||||
if err != nil {
|
||||
fc.Close()
|
||||
client.Close()
|
||||
ctx.SetStatusCode(404)
|
||||
ctx.SetBodyString("文件不可读:" + err.Error())
|
||||
return nil
|
||||
}
|
||||
if fi, err := f.Stat(); err == nil {
|
||||
ctx.Response.Header.Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
|
||||
}
|
||||
name := path.Base(p)
|
||||
ctx.Response.Header.Set("Content-Disposition", "attachment; filename*=UTF-8''"+urlPathEscape(name))
|
||||
ctx.SetContentType("application/octet-stream")
|
||||
// 流式回传:连接生命周期交给 StreamWriter 收尾
|
||||
ctx.SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
_, _ = io.Copy(w, f)
|
||||
f.Close()
|
||||
fc.Close()
|
||||
client.Close()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func urlPathEscape(s string) string {
|
||||
var b strings.Builder
|
||||
for _, c := range []byte(s) {
|
||||
if c < 0x20 || c > 0x7e || strings.ContainsRune("\"%;+", rune(c)) {
|
||||
fmt.Fprintf(&b, "%%%02X", c)
|
||||
} else {
|
||||
b.WriteByte(c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// SftpUploadHandler POST /files/upload?id=&dir=&token= multipart 字段 file
|
||||
func SftpUploadHandler(st *store.Store, cfg *config.Config) fun.RouteHandler {
|
||||
return func(rc *fun.RouteCtx) error {
|
||||
ctx := rc.RequestCtx
|
||||
if err := routeAdmin(st, ctx); err != nil {
|
||||
ctx.SetBodyString(`{"status":2,"msg":"未授权"}`)
|
||||
return nil
|
||||
}
|
||||
e, err := routeServer(st, ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
fh, err := ctx.FormFile("file")
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(400)
|
||||
ctx.SetBodyString(`{"status":2,"msg":"缺少文件"}`)
|
||||
return nil
|
||||
}
|
||||
src, err := fh.Open()
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(400)
|
||||
ctx.SetBodyString(`{"status":2,"msg":"读取上传失败"}`)
|
||||
return nil
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
client, err := dialServer(e, cfg.VaultKey)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(502)
|
||||
ctx.SetBodyString(`{"status":2,"msg":"SSH 连接失败"}`)
|
||||
return nil
|
||||
}
|
||||
defer client.Close()
|
||||
fc, err := sftp.NewClient(client)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(502)
|
||||
ctx.SetBodyString(`{"status":2,"msg":"SFTP 初始化失败"}`)
|
||||
return nil
|
||||
}
|
||||
defer fc.Close()
|
||||
|
||||
dir := cleanPath(string(ctx.QueryArgs().Peek("dir")))
|
||||
dst := path.Join(dir, path.Base(fh.Filename))
|
||||
w, err := fc.Create(dst)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(500)
|
||||
ctx.SetBodyString(`{"status":2,"msg":"创建远端文件失败:` + err.Error() + `"}`)
|
||||
return nil
|
||||
}
|
||||
if _, err := io.Copy(w, src); err != nil {
|
||||
w.Close()
|
||||
ctx.SetStatusCode(500)
|
||||
ctx.SetBodyString(`{"status":2,"msg":"写入失败:` + err.Error() + `"}`)
|
||||
return nil
|
||||
}
|
||||
w.Close()
|
||||
ctx.SetContentType("application/json")
|
||||
ctx.SetBodyString(`{"status":0,"msg":"ok"}`)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// TerminalHandler GET /ws/terminal?id=&token= 升级 WebSocket → SSH PTY。
|
||||
// 协议:客户端→服务端首字节 0x01 为 JSON 控制帧 {"cols":N,"rows":N}(resize),
|
||||
// 其余原样写入 shell stdin;服务端→客户端全部原样回显。
|
||||
func TerminalHandler(st *store.Store, cfg *config.Config) fun.RouteHandler {
|
||||
upgrader := websocket.FastHTTPUpgrader{
|
||||
CheckOrigin: func(*fasthttp.RequestCtx) bool { return true },
|
||||
}
|
||||
return func(rc *fun.RouteCtx) error {
|
||||
ctx := rc.RequestCtx
|
||||
if err := routeAdmin(st, ctx); err != nil {
|
||||
ctx.SetBodyString("unauthorized")
|
||||
return nil
|
||||
}
|
||||
e, err := routeServer(st, ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
client, err := dialServer(e, cfg.VaultKey)
|
||||
if err != nil {
|
||||
ctx.SetStatusCode(502)
|
||||
ctx.SetBodyString("SSH 连接失败:" + err.Error())
|
||||
return nil
|
||||
}
|
||||
// Upgrade 内部 hijack 连接后异步跑回调;ssh.Client 生命周期归 runTerm
|
||||
if err := upgrader.Upgrade(ctx, func(ws *websocket.Conn) {
|
||||
runTerm(ws, client)
|
||||
}); err != nil {
|
||||
client.Close()
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type termResize struct {
|
||||
Cols int `json:"cols"`
|
||||
Rows int `json:"rows"`
|
||||
}
|
||||
|
||||
func runTerm(ws *websocket.Conn, client *ssh.Client) {
|
||||
defer ws.Close()
|
||||
defer client.Close()
|
||||
sess, err := client.NewSession()
|
||||
if err != nil {
|
||||
_ = ws.WriteMessage(websocket.TextMessage, []byte("\r\nopen session failed: "+err.Error()+"\r\n"))
|
||||
return
|
||||
}
|
||||
defer sess.Close()
|
||||
modes := ssh.TerminalModes{
|
||||
ssh.ECHO: 1,
|
||||
ssh.TTY_OP_ISPEED: 14400,
|
||||
ssh.TTY_OP_OSPEED: 14400,
|
||||
}
|
||||
if err := sess.RequestPty("xterm-256color", 24, 80, modes); err != nil {
|
||||
_ = ws.WriteMessage(websocket.TextMessage, []byte("\r\npty failed: "+err.Error()+"\r\n"))
|
||||
return
|
||||
}
|
||||
stdin, err := sess.StdinPipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
stdout, err := sess.StdoutPipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
sess.Stderr = sess.Stdout
|
||||
if err := sess.Shell(); err != nil {
|
||||
_ = ws.WriteMessage(websocket.TextMessage, []byte("\r\nshell failed: "+err.Error()+"\r\n"))
|
||||
return
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
// ws → stdin
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
_, data, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
_ = sess.Close()
|
||||
return
|
||||
}
|
||||
if len(data) > 0 && data[0] == 0x01 {
|
||||
var r termResize
|
||||
if json.Unmarshal(data[1:], &r) == nil && r.Cols > 0 && r.Rows > 0 {
|
||||
_ = sess.WindowChange(r.Rows, r.Cols)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := stdin.Write(data); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// stdout → ws
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := stdout.Read(buf)
|
||||
if n > 0 {
|
||||
if werr := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); werr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
_ = sess.Close()
|
||||
<-done
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/dto"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
)
|
||||
|
||||
// SettingSvc 站点级 KV 设置:读取对所有登录用户开放,写入仅管理员
|
||||
type SettingSvc struct {
|
||||
fun.Ctx
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
func (s *SettingSvc) Get(d dto.SettingDto) (dto.SettingView, error) {
|
||||
key := strings.TrimSpace(d.Key)
|
||||
if key == "" || len(key) > 64 {
|
||||
return dto.SettingView{}, fun.Error(4001, "key 必填且不超过 64 字符")
|
||||
}
|
||||
v, err := s.Store.GetSetting(key)
|
||||
if err != nil {
|
||||
return dto.SettingView{}, err
|
||||
}
|
||||
return dto.SettingView{Value: v}, nil
|
||||
}
|
||||
|
||||
func (s *SettingSvc) Set(d dto.SettingDto) (dto.OkView, error) {
|
||||
key := strings.TrimSpace(d.Key)
|
||||
if key == "" || len(key) > 64 || len(d.Value) > 2000 {
|
||||
return dto.OkView{}, fun.Error(4001, "key 必填且不超过 64 字符,value 不超过 2000 字符")
|
||||
}
|
||||
if err := s.Store.SetSetting(key, strings.TrimSpace(d.Value)); err != nil {
|
||||
return dto.OkView{}, err
|
||||
}
|
||||
return dto.OkView{Message: "已保存"}, nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/dto"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
)
|
||||
|
||||
// ToolSvc 工具箱注册表:List 登录用户可见(驱动侧边导航),管理端点为 admin
|
||||
type ToolSvc struct {
|
||||
fun.Ctx
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
func (s *ToolSvc) List() ([]dto.ToolView, error) {
|
||||
tools, err := s.Store.ListTools(true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tool: list failed: %w", err)
|
||||
}
|
||||
return toolViews(tools), nil
|
||||
}
|
||||
|
||||
func (s *ToolSvc) AdminList() ([]dto.ToolView, error) {
|
||||
tools, err := s.Store.ListTools(false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tool: list failed: %w", err)
|
||||
}
|
||||
return toolViews(tools), nil
|
||||
}
|
||||
|
||||
func (s *ToolSvc) SaveTool(d dto.SaveToolDto) (dto.ToolView, error) {
|
||||
name := strings.TrimSpace(d.Name)
|
||||
url := strings.TrimSpace(d.Url)
|
||||
if name == "" || len(name) > 32 {
|
||||
return dto.ToolView{}, fun.Error(4001, "工具名必填且不超过 32 字符")
|
||||
}
|
||||
t := store.Tool{
|
||||
Name: name,
|
||||
Url: url,
|
||||
Sort: 0,
|
||||
Enabled: true,
|
||||
}
|
||||
if d.Id != nil {
|
||||
t.Id = *d.Id
|
||||
}
|
||||
if d.Icon != nil {
|
||||
t.Icon = strings.TrimSpace(*d.Icon)
|
||||
}
|
||||
if d.Description != nil {
|
||||
t.Description = strings.TrimSpace(*d.Description)
|
||||
}
|
||||
if d.Sort != nil {
|
||||
t.Sort = *d.Sort
|
||||
}
|
||||
if d.Enabled != nil {
|
||||
t.Enabled = *d.Enabled
|
||||
}
|
||||
if err := s.Store.SaveTool(&t); err != nil {
|
||||
return dto.ToolView{}, fmt.Errorf("tool: save failed: %w", err)
|
||||
}
|
||||
views := toolViews([]store.Tool{t})
|
||||
return views[0], nil
|
||||
}
|
||||
|
||||
func (s *ToolSvc) DeleteTool(d dto.DeleteToolDto) (dto.OkView, error) {
|
||||
if err := s.Store.DeleteTool(d.Id); err != nil {
|
||||
return dto.OkView{}, fmt.Errorf("tool: delete failed: %w", err)
|
||||
}
|
||||
return dto.OkView{Message: "已删除"}, nil
|
||||
}
|
||||
|
||||
func toolViews(tools []store.Tool) []dto.ToolView {
|
||||
views := make([]dto.ToolView, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
views = append(views, dto.ToolView{
|
||||
Id: t.Id,
|
||||
Name: t.Name,
|
||||
Icon: t.Icon,
|
||||
Url: t.Url,
|
||||
Description: t.Description,
|
||||
Sort: t.Sort,
|
||||
Enabled: t.Enabled,
|
||||
})
|
||||
}
|
||||
return views
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/dto"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
)
|
||||
|
||||
// UserSvc 用户管理:全部 policyAdmin, Guard 已保证仅管理员可达
|
||||
type UserSvc struct {
|
||||
fun.Ctx
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
func (s *UserSvc) ListUsers(d dto.ListUsersDto) (dto.UserPageView, error) {
|
||||
page := max(d.Page, 1)
|
||||
pageSize := min(max(d.PageSize, 1), 100)
|
||||
keyword := ""
|
||||
if d.Keyword != nil {
|
||||
keyword = strings.TrimSpace(*d.Keyword)
|
||||
}
|
||||
users, total, err := s.Store.ListUsers(keyword, page, pageSize)
|
||||
if err != nil {
|
||||
return dto.UserPageView{}, fmt.Errorf("user: list failed: %w", err)
|
||||
}
|
||||
items := make([]dto.UserView, 0, len(users))
|
||||
for _, u := range users {
|
||||
items = append(items, userView(u))
|
||||
}
|
||||
return dto.UserPageView{Total: total, Items: items}, nil
|
||||
}
|
||||
|
||||
// SaveUser Id 为空创建(密码必填),非空更新(密码留空不变)
|
||||
func (s *UserSvc) SaveUser(d dto.SaveUserDto) (dto.UserView, error) {
|
||||
name := strings.TrimSpace(d.Name)
|
||||
if name == "" || len(name) > 32 {
|
||||
return dto.UserView{}, fun.Error(4001, "用户名必填且不超过 32 字符")
|
||||
}
|
||||
if d.Role != "admin" && d.Role != "user" {
|
||||
return dto.UserView{}, fun.Error(4001, "角色无效")
|
||||
}
|
||||
|
||||
if d.Id == nil {
|
||||
if d.Password == nil || len(*d.Password) < 6 {
|
||||
return dto.UserView{}, fun.Error(4001, "密码至少 6 位")
|
||||
}
|
||||
if _, err := s.Store.GetUserByName(name); err == nil {
|
||||
return dto.UserView{}, fun.Error(4001, "用户名已存在")
|
||||
} else if !errors.Is(err, store.ErrNotFound) {
|
||||
return dto.UserView{}, err
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*d.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return dto.UserView{}, err
|
||||
}
|
||||
u, err := s.Store.CreateUser(name, string(hash), d.Role)
|
||||
if err != nil {
|
||||
return dto.UserView{}, fmt.Errorf("user: create failed: %w", err)
|
||||
}
|
||||
return userView(u), nil
|
||||
}
|
||||
|
||||
u, err := s.Store.GetUserByID(*d.Id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return dto.UserView{}, fun.Error(4001, "用户不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return dto.UserView{}, err
|
||||
}
|
||||
if existing, err := s.Store.GetUserByName(name); err == nil && existing.Id != u.Id {
|
||||
return dto.UserView{}, fun.Error(4001, "用户名已存在")
|
||||
}
|
||||
status := u.Status
|
||||
if d.Status != nil {
|
||||
if *d.Status != "active" && *d.Status != "disabled" {
|
||||
return dto.UserView{}, fun.Error(4001, "状态无效")
|
||||
}
|
||||
status = *d.Status
|
||||
}
|
||||
if err := s.guardLastAdmin(u, d.Role, status); err != nil {
|
||||
return dto.UserView{}, err
|
||||
}
|
||||
var hash *string
|
||||
if d.Password != nil && *d.Password != "" {
|
||||
if len(*d.Password) < 6 {
|
||||
return dto.UserView{}, fun.Error(4001, "密码至少 6 位")
|
||||
}
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(*d.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return dto.UserView{}, err
|
||||
}
|
||||
hs := string(h)
|
||||
hash = &hs
|
||||
}
|
||||
if err := s.Store.UpdateUser(u.Id, name, d.Role, status, hash); err != nil {
|
||||
return dto.UserView{}, fmt.Errorf("user: update failed: %w", err)
|
||||
}
|
||||
u, err = s.Store.GetUserByID(u.Id)
|
||||
if err != nil {
|
||||
return dto.UserView{}, err
|
||||
}
|
||||
return userView(u), nil
|
||||
}
|
||||
|
||||
func (s *UserSvc) DeleteUser(d dto.DeleteUserDto) (dto.OkView, error) {
|
||||
su, err := sessionFrom(s.RequestCtx)
|
||||
if err != nil {
|
||||
return dto.OkView{}, err
|
||||
}
|
||||
if d.Id == su.User.Id {
|
||||
return dto.OkView{}, fun.Error(4001, "不能删除当前登录账号")
|
||||
}
|
||||
u, err := s.Store.GetUserByID(d.Id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return dto.OkView{}, fun.Error(4001, "用户不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return dto.OkView{}, err
|
||||
}
|
||||
if err := s.guardLastAdmin(u, "user", "disabled"); err != nil {
|
||||
return dto.OkView{}, err
|
||||
}
|
||||
if err := s.Store.DeleteUser(d.Id); err != nil {
|
||||
return dto.OkView{}, fmt.Errorf("user: delete failed: %w", err)
|
||||
}
|
||||
return dto.OkView{Message: "已删除"}, nil
|
||||
}
|
||||
|
||||
// guardLastAdmin 阻止把最后一个可用管理员降级/禁用/删除
|
||||
func (s *UserSvc) guardLastAdmin(u store.User, newRole, newStatus string) error {
|
||||
if u.Role == "admin" && u.Status == "active" && (newRole != "admin" || newStatus != "active") {
|
||||
n, err := s.Store.CountAdmins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n <= 1 {
|
||||
return fun.Error(4001, "至少需要保留一个可用管理员")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/config"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/dto"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/store"
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/vault"
|
||||
)
|
||||
|
||||
// VaultSvc 密码箱:密文只在服务端出现;列表只回元数据,
|
||||
// 密码经 Reveal 显式取回,2FA 只回当前验证码不回密钥。
|
||||
type VaultSvc struct {
|
||||
fun.Ctx
|
||||
Store *store.Store
|
||||
Cfg *config.Config `fun:"auto"`
|
||||
}
|
||||
|
||||
func (s *VaultSvc) owner() (store.SessionUser, error) {
|
||||
return sessionFrom(s.RequestCtx)
|
||||
}
|
||||
|
||||
func (s *VaultSvc) List() ([]dto.VaultItemView, error) {
|
||||
su, err := s.owner()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := s.Store.ListVault(su.User.Id, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vault: list failed: %w", err)
|
||||
}
|
||||
views := make([]dto.VaultItemView, 0, len(items))
|
||||
for _, e := range items {
|
||||
views = append(views, vaultView(e))
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
func (s *VaultSvc) Save(d dto.SaveVaultDto) (dto.VaultItemView, error) {
|
||||
su, err := s.owner()
|
||||
if err != nil {
|
||||
return dto.VaultItemView{}, err
|
||||
}
|
||||
title := strings.TrimSpace(d.Title)
|
||||
if title == "" || len(title) > 64 {
|
||||
return dto.VaultItemView{}, fun.Error(4001, "标题必填且不超过 64 字符")
|
||||
}
|
||||
|
||||
e := store.VaultEntry{UserID: su.User.Id, Title: title}
|
||||
if d.Id != nil {
|
||||
old, err := s.Store.GetVault(su.User.Id, *d.Id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return dto.VaultItemView{}, fun.Error(4001, "条目不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return dto.VaultItemView{}, err
|
||||
}
|
||||
e = old
|
||||
e.Title = title
|
||||
}
|
||||
if d.Username != nil {
|
||||
e.Username = strings.TrimSpace(*d.Username)
|
||||
}
|
||||
if d.Url != nil {
|
||||
e.Url = strings.TrimSpace(*d.Url)
|
||||
}
|
||||
if d.Note != nil {
|
||||
e.Note = strings.TrimSpace(*d.Note)
|
||||
}
|
||||
|
||||
if d.Password != nil {
|
||||
enc, err := vault.Encrypt(s.Cfg.VaultKey, *d.Password)
|
||||
if err != nil {
|
||||
return dto.VaultItemView{}, err
|
||||
}
|
||||
e.PasswordEnc = enc
|
||||
}
|
||||
if d.TotpSecret != nil {
|
||||
secret := strings.TrimSpace(*d.TotpSecret)
|
||||
if secret == "" {
|
||||
e.TotpEnc = "" // 空字符串 = 清除 2FA
|
||||
} else {
|
||||
parsed, err := vault.ParseSecret(secret)
|
||||
if err != nil {
|
||||
return dto.VaultItemView{}, fun.Error(4001, "2FA 密钥无效:支持 base32 或 otpauth:// 链接")
|
||||
}
|
||||
enc, err := vault.Encrypt(s.Cfg.VaultKey, parsed)
|
||||
if err != nil {
|
||||
return dto.VaultItemView{}, err
|
||||
}
|
||||
e.TotpEnc = enc
|
||||
}
|
||||
}
|
||||
if err := s.Store.SaveVault(&e); err != nil {
|
||||
return dto.VaultItemView{}, fmt.Errorf("vault: save failed: %w", err)
|
||||
}
|
||||
return vaultView(e), nil
|
||||
}
|
||||
|
||||
// Reveal 显式取回明文密码(前端点「查看」才调用,列表永不携带)
|
||||
func (s *VaultSvc) Reveal(d dto.VaultIdDto) (dto.SecretView, error) {
|
||||
su, err := s.owner()
|
||||
if err != nil {
|
||||
return dto.SecretView{}, err
|
||||
}
|
||||
e, err := s.Store.GetVault(su.User.Id, d.Id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return dto.SecretView{}, fun.Error(4001, "条目不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return dto.SecretView{}, err
|
||||
}
|
||||
if e.PasswordEnc == "" {
|
||||
return dto.SecretView{}, fun.Error(4001, "该条目没有密码")
|
||||
}
|
||||
pt, err := vault.Decrypt(s.Cfg.VaultKey, e.PasswordEnc)
|
||||
if err != nil {
|
||||
return dto.SecretView{}, fmt.Errorf("vault: decrypt failed: %w", err)
|
||||
}
|
||||
return dto.SecretView{Value: pt}, nil
|
||||
}
|
||||
|
||||
// Totp 返回当前 6 位验证码与剩余秒数;密钥不出服务端
|
||||
func (s *VaultSvc) Totp(d dto.VaultIdDto) (dto.TotpView, error) {
|
||||
su, err := s.owner()
|
||||
if err != nil {
|
||||
return dto.TotpView{}, err
|
||||
}
|
||||
e, err := s.Store.GetVault(su.User.Id, d.Id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return dto.TotpView{}, fun.Error(4001, "条目不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return dto.TotpView{}, err
|
||||
}
|
||||
if e.TotpEnc == "" {
|
||||
return dto.TotpView{}, fun.Error(4001, "该条目未设置 2FA")
|
||||
}
|
||||
secret, err := vault.Decrypt(s.Cfg.VaultKey, e.TotpEnc)
|
||||
if err != nil {
|
||||
return dto.TotpView{}, fmt.Errorf("vault: decrypt totp failed: %w", err)
|
||||
}
|
||||
code, left, err := vault.TOTP(secret, time.Now())
|
||||
if err != nil {
|
||||
return dto.TotpView{}, fun.Error(4001, "2FA 密钥无效")
|
||||
}
|
||||
return dto.TotpView{Code: code, SecondsLeft: left}, nil
|
||||
}
|
||||
|
||||
func (s *VaultSvc) Delete(d dto.VaultIdDto) (dto.OkView, error) {
|
||||
su, err := s.owner()
|
||||
if err != nil {
|
||||
return dto.OkView{}, err
|
||||
}
|
||||
if err := s.Store.DeleteVault(su.User.Id, d.Id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return dto.OkView{}, fun.Error(4001, "条目不存在")
|
||||
}
|
||||
return dto.OkView{}, fmt.Errorf("vault: delete failed: %w", err)
|
||||
}
|
||||
return dto.OkView{Message: "已删除"}, nil
|
||||
}
|
||||
|
||||
func vaultView(e store.VaultEntry) dto.VaultItemView {
|
||||
v := dto.VaultItemView{
|
||||
Id: e.Id,
|
||||
Title: e.Title,
|
||||
HasPassword: e.PasswordEnc != "",
|
||||
HasTotp: e.TotpEnc != "",
|
||||
UpdatedAt: e.UpdatedAt,
|
||||
}
|
||||
if e.Username != "" {
|
||||
v.Username = &e.Username
|
||||
}
|
||||
if e.Url != "" {
|
||||
v.Url = &e.Url
|
||||
}
|
||||
if e.Note != "" {
|
||||
v.Note = &e.Note
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cyi-cc/fun"
|
||||
"github.com/valyala/fasthttp"
|
||||
|
||||
"github.com/cyi-cc/cyi-box/backend/internal/dto"
|
||||
)
|
||||
|
||||
const (
|
||||
webReqTimeout = 30 * time.Second
|
||||
webRespMaxBody = 2 << 20 // 响应体截断上限 2MB
|
||||
webReqMaxBody = 1 << 20 // 请求体上限 1MB
|
||||
webMaxRedirect = 5
|
||||
webMaxHeaderPairs = 64
|
||||
)
|
||||
|
||||
// fun autowired 只处理导出字段,client 用包级单例复用
|
||||
var webClient = &fasthttp.Client{
|
||||
Name: "cyi-box-webreq",
|
||||
MaxConnsPerHost: 16,
|
||||
ReadTimeout: webReqTimeout,
|
||||
WriteTimeout: webReqTimeout,
|
||||
MaxConnWaitTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// WebSvc HTTP 请求代理(Postman 工具):服务端代发以绕开浏览器 CORS。
|
||||
// 只放行 http/https;headersJson 为 {"k":"v"} 对象。
|
||||
type WebSvc struct {
|
||||
fun.Ctx
|
||||
}
|
||||
|
||||
func (s *WebSvc) Fetch(d dto.WebFetchDto) (dto.WebFetchView, error) {
|
||||
method := strings.ToUpper(strings.TrimSpace(d.Method))
|
||||
switch method {
|
||||
case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS":
|
||||
default:
|
||||
return dto.WebFetchView{}, fun.Error(4001, "不支持的请求方法: "+method)
|
||||
}
|
||||
url := strings.TrimSpace(d.Url)
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
return dto.WebFetchView{}, fun.Error(4001, "URL 必须以 http:// 或 https:// 开头")
|
||||
}
|
||||
if len(d.Body) > webReqMaxBody {
|
||||
return dto.WebFetchView{}, fun.Error(4001, "请求体超过 1MB 上限")
|
||||
}
|
||||
|
||||
var headers map[string]string
|
||||
if strings.TrimSpace(d.HeadersJson) != "" {
|
||||
if err := json.Unmarshal([]byte(d.HeadersJson), &headers); err != nil {
|
||||
return dto.WebFetchView{}, fun.Error(4001, "请求头 JSON 解析失败: "+err.Error())
|
||||
}
|
||||
if len(headers) > webMaxHeaderPairs {
|
||||
return dto.WebFetchView{}, fun.Error(4001, "请求头数量超过上限")
|
||||
}
|
||||
}
|
||||
|
||||
req := fasthttp.AcquireRequest()
|
||||
resp := fasthttp.AcquireResponse()
|
||||
defer fasthttp.ReleaseRequest(req)
|
||||
defer fasthttp.ReleaseResponse(resp)
|
||||
|
||||
req.Header.SetMethod(method)
|
||||
req.SetRequestURI(url)
|
||||
for k, v := range headers {
|
||||
k = strings.TrimSpace(k)
|
||||
if k == "" || strings.EqualFold(k, "Host") || strings.EqualFold(k, "Content-Length") {
|
||||
continue
|
||||
}
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if d.Body != "" {
|
||||
req.SetBodyString(d.Body)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
err := webClient.DoRedirects(req, resp, webMaxRedirect)
|
||||
elapsed := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return dto.WebFetchView{}, fun.Error(4002, "请求失败: "+err.Error())
|
||||
}
|
||||
|
||||
view := dto.WebFetchView{
|
||||
Status: int64(resp.StatusCode()),
|
||||
ElapsedMs: elapsed,
|
||||
}
|
||||
respHeaders := map[string]string{}
|
||||
resp.Header.VisitAll(func(k, v []byte) {
|
||||
respHeaders[string(k)] = string(v)
|
||||
})
|
||||
if b, e := json.Marshal(respHeaders); e == nil {
|
||||
view.HeadersJson = string(b)
|
||||
}
|
||||
body := resp.Body()
|
||||
if len(body) > webRespMaxBody {
|
||||
body = body[:webRespMaxBody]
|
||||
view.Truncated = true
|
||||
}
|
||||
view.Body = string(body)
|
||||
return view, nil
|
||||
}
|
||||
Reference in New Issue
Block a user