Files
cmautobuy/admin/service/service.go
T

290 lines
10 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("功能尚未实现")
// ErrTaskNotVisible 同时表示任务不存在和不在当前账号范围,避免泄露他人任务。
var ErrTaskNotVisible = errors.New("任务不存在或不在当前账号可见范围")
// ---------- 采集 ----------
// 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
PurchaseMode 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
}
// taskActorScope 校验可创建/管理任务的网页登录账号,并返回客户端/任务可见范围。
func taskActorScope(actor *model.User) (string, error) {
if actor == nil {
return "", ErrUnauthenticated
}
if actor.Status != model.UserActive {
return "", fmt.Errorf("当前账号不是正常状态,不能操作任务")
}
return visibleClientUserID(actor)
}
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),
PurchaseMode: repository.ParseClientPurchaseMode(c.Capabilities),
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),
PurchaseMode: repository.ParseClientPurchaseMode(client.Capabilities),
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)
}
// PurchaseClientOptions 是采购弹窗的客户端候选和可选数量。
// 非 live Client 也返回给页面显示禁用原因;创建时仍会在事务内复核能力。
type PurchaseClientOptions struct {
Rows []ClientView
SelectableCount int
}
// ListPurchaseClientOptions 返回当前用户可见的采购客户端。
// live Client 即使暂时离线也可提前指派,等它上线后再领取任务。
func ListPurchaseClientOptions(db *sql.DB, actor *model.User, threshold time.Duration) (*PurchaseClientOptions, error) {
clients, err := ListClientViewsForUser(db, actor, "", threshold)
if err != nil {
return nil, err
}
result := &PurchaseClientOptions{Rows: clients}
result.SelectableCount = len(clients)
return result, nil
}
// 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, purchaseModes ...string) (*model.Task, error) {
return repository.ClaimNextTask(db, clientID, supportedTypes, purchaseModes...)
}