Files
cmautobuy/admin/service/service.go
T

258 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package service 放业务逻辑。
//
// 本包**不认识 *gin.Context**——这样才能不起服务器就写单元测试。
// handler 负责取参数,service 负责判断和编排,repository 负责读写数据库。
//
// 骨架阶段这里只有函数签名和 TODO,实现按工单逐个补。
package service
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
// ErrNotImplemented 表示该功能还没实现。
// 补完实现后要把对应的返回删掉,不要留着假装能用。
var ErrNotImplemented = errors.New("功能尚未实现")
// ---------- 蝦皮 Excel 导入 ----------
//
// ImportResult / ImportShopeeExcel / ParseSpec 的实现见 shopee_import.go,
// 那个文件还负责上传文件的安全校验(大小、扩展名、内容魔数)。
// ---------- 采集 ----------
// CreateCollectTasks 为若干蝦皮商品创建采集任务。
//
// 规则:
// - 按 goods_id **去重**(一个商品有多个 SKU 行,别建重复任务);
// - PDD 链接为空的跳过;
// - collect_status 已是 collecting 的跳过,并在结果里说明跳过了几个;
// - 建任务成功后把 collect_status 置为 collecting。
func CreateCollectTasks(db *sql.DB, goodsIDs []string, clientID string) (created, skipped int, err error) {
// TODO(骨架)
return 0, 0, ErrNotImplemented
}
// ---------- 采购任务 ----------
// TaskCreateError 说明某一条为什么建不了任务。
// **不要静默跳过**,要把这些列出来告诉操作员缺什么。
type TaskCreateError struct {
SybID string
Reason string // 例如「该商品未填写 PDD 链接」
}
// ---------- 客户端 ----------
// RegisterClient 登记或更新一台客户端。
//
// explicit 的含义见 repository.UpsertClient 的说明:
// true = 设置页点保存(会更新名称),false = claim 顺带(不更新名称)。
func RegisterClient(db repository.Execer, c model.Client, explicit bool) error {
return repository.UpsertClient(db, c, explicit)
}
// RegisterClientProfile 处理设置页发起的**显式登记**。
//
// `[必须]` 本函数**不读取、不领取、不修改任何任务**,也不返回任务。
// 这正是它存在的理由:设置页点"保存"不该顺带把一个任务领走——
// 领走了 Admin 就把任务标成 claimed 了,而保存动作没有义务
// 去可靠保存那个任务,任务就丢了。
func RegisterClientProfile(db *sql.DB, clientID string, p ClientProfileRequest) (string, error) {
if clientID == "" {
return "", fmt.Errorf("client_id 不能为空")
}
if err := p.Validate(); err != nil {
return "", err
}
if err := RegisterClient(db, p.ToClient(clientID), true); err != nil {
return "", err
}
return model.NowISO(), nil
}
// TouchClient 刷新 last_seen_at。
//
// claim / result / failure **三个接口都要调**。
// 只在 claim 里调的话,客户端执行长任务期间不调 claim,
// 会被误判成离线。
func TouchClient(db *sql.DB, clientID string) error {
return repository.TouchClient(db, clientID)
}
// ClientView 是客户端列表页要显示的一行。
// Status 是**算出来的**,数据库里没有这个字段。
type ClientView struct {
model.Client
Status string
AssignedUserID string
AssignedUsername string
}
// ListClientViews 查客户端列表,并把在线状态算出来。
func ListClientViews(db *sql.DB, keyword string, threshold time.Duration) ([]ClientView, error) {
return listClientViews(db, keyword, "", threshold)
}
// ListClientViewsForUser 按网页登录身份限制可见范围:管理员全量,采购员只看自己。
func ListClientViewsForUser(db *sql.DB, actor *model.User, keyword string, threshold time.Duration) ([]ClientView, error) {
visibleUserID, err := visibleClientUserID(actor)
if err != nil {
return nil, err
}
return listClientViews(db, keyword, visibleUserID, threshold)
}
func visibleClientUserID(actor *model.User) (string, error) {
if actor == nil {
return "", ErrUnauthenticated
}
visibleUserID := ""
if !actor.IsAdmin() {
if actor.Role != model.RolePurchaser {
return "", ErrAdminRequired
}
visibleUserID = actor.UserID
}
return visibleUserID, nil
}
func listClientViews(db *sql.DB, keyword, visibleUserID string, threshold time.Duration) ([]ClientView, error) {
clients, err := repository.ListClientsForUser(db, keyword, visibleUserID)
if err != nil {
return nil, err
}
now := time.Now().UTC()
views := make([]ClientView, 0, len(clients))
for _, c := range clients {
views = append(views, ClientView{
Client: c.Client,
Status: c.StatusText(now, threshold),
AssignedUserID: c.AssignedUserID,
AssignedUsername: c.AssignedUsername,
})
}
return views, nil
}
// ClientListResult 是客户端页面的一页数据和同一权限范围内的完整统计。
type ClientListResult struct {
Rows []ClientView
Page int
Total int
TotalPages int
Online int
Offline int
}
// ListClientPageForUser 按网页登录身份、搜索词和统一页大小返回客户端列表。
func ListClientPageForUser(db *sql.DB, actor *model.User, keyword string, threshold time.Duration, requestedPage int) (*ClientListResult, error) {
visibleUserID, err := visibleClientUserID(actor)
if err != nil {
return nil, err
}
now := time.Now().UTC()
page := requestedPage
if page < 1 {
page = 1
}
rows, total, err := repository.ListClientsForUserPage(db, keyword, visibleUserID, PageSize, (page-1)*PageSize)
if err != nil {
return nil, err
}
totalPages := TotalPages(total)
clampedPage := ClampPage(page, totalPages)
if clampedPage != page {
page = clampedPage
rows, _, err = repository.ListClientsForUserPage(db, keyword, visibleUserID, PageSize, (page-1)*PageSize)
if err != nil {
return nil, err
}
}
online, err := repository.CountOnlineClientsForUser(
db, keyword, visibleUserID, now.Add(-threshold).Format(model.TimeLayout))
if err != nil {
return nil, err
}
views := make([]ClientView, 0, len(rows))
for _, client := range rows {
views = append(views, ClientView{
Client: client.Client, Status: client.StatusText(now, threshold),
AssignedUserID: client.AssignedUserID, AssignedUsername: client.AssignedUsername,
})
}
return &ClientListResult{
Rows: views, Page: page, Total: total, TotalPages: totalPages,
Online: online, Offline: total - online,
}, nil
}
// ListAssignableClients 返回当前用户在采购任务页面可选择的客户端。
// 当前创建页面尚未实现,本函数固定未来入口也必须沿用相同权限边界。
func ListAssignableClients(db *sql.DB, actor *model.User, threshold time.Duration) ([]ClientView, error) {
return ListClientViewsForUser(db, actor, "", threshold)
}
// ListActivePurchasers 返回管理员可选择的绑定目标。
func ListActivePurchasers(db *sql.DB, actor *model.User) ([]model.User, error) {
if actor == nil || !actor.IsAdmin() {
return nil, ErrAdminRequired
}
return repository.ListActivePurchasers(db)
}
// AssignClient 由管理员完成首次绑定或转交。
func AssignClient(db *sql.DB, actor *model.User, clientID, purchaserUserID string, now time.Time) (bool, bool, error) {
if actor == nil || !actor.IsAdmin() {
return false, false, ErrAdminRequired
}
clientID = strings.TrimSpace(clientID)
purchaserUserID = strings.TrimSpace(purchaserUserID)
if clientID == "" || purchaserUserID == "" {
return false, false, invalidInput("客户端和采购员都不能为空")
}
id, err := randomID("CA-", 16)
if err != nil {
return false, false, err
}
at := now.UTC().Format(model.TimeLayout)
return repository.AssignClient(db, model.ClientUserAssignment{
AssignmentID: id, ClientID: clientID, UserID: purchaserUserID,
StartedAt: at, AssignedByUserID: actor.UserID,
})
}
// UnassignClient 由管理员结束当前归属,不改动任何任务。
func UnassignClient(db *sql.DB, actor *model.User, clientID string, now time.Time) error {
if actor == nil || !actor.IsAdmin() {
return ErrAdminRequired
}
clientID = strings.TrimSpace(clientID)
if clientID == "" {
return invalidInput("客户端不能为空")
}
return repository.UnassignClient(db, clientID, actor.UserID, now.UTC().Format(model.TimeLayout))
}
// DeleteClients 批量删除,返回实际删除条数。
func DeleteClients(db *sql.DB, clientIDs []string) (int64, error) {
return repository.DeleteClients(db, clientIDs)
}
// ---------- 领取任务 ----------
// ClaimNextTask 为客户端领取一个任务,没有可领的返回 (nil, nil)。
//
// 调用方拿到 nil 要返回 204 No Content,**不是 200 加空对象**。
func ClaimNextTask(db *sql.DB, clientID string, supportedTypes []string) (*model.Task, error) {
return repository.ClaimNextTask(db, clientID, supportedTypes)
}