feat: migrate workbench services
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
accountsvc "git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
type accountRegisterRequest struct {
|
||||
Username string `json:"username"`
|
||||
Phone string `json:"phone"`
|
||||
Password string `json:"password"`
|
||||
ReferralCode string `json:"referral_code,omitempty"`
|
||||
PrivacyAccepted bool `json:"privacy_accepted"`
|
||||
}
|
||||
|
||||
type accountLoginRequest struct {
|
||||
Phone string `json:"phone"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (a *API) accountRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
if !a.validatePublicClient(w, r) {
|
||||
return
|
||||
}
|
||||
var body accountRegisterRequest
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.accounts.Register(r.Context(), accountsvc.RegisterInput{
|
||||
Username: body.Username, Phone: body.Phone, Password: body.Password,
|
||||
ReferralCode: body.ReferralCode, PrivacyAccepted: body.PrivacyAccepted,
|
||||
DeviceID: strings.TrimSpace(r.Header.Get("X-Device-ID")),
|
||||
})
|
||||
if err != nil {
|
||||
writeAccountError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, accountResultPayload(result))
|
||||
}
|
||||
|
||||
func (a *API) accountLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
if !a.validatePublicClient(w, r) {
|
||||
return
|
||||
}
|
||||
var body accountLoginRequest
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.accounts.Login(r.Context(), accountsvc.LoginInput{
|
||||
Phone: body.Phone, Password: body.Password,
|
||||
DeviceID: strings.TrimSpace(r.Header.Get("X-Device-ID")),
|
||||
})
|
||||
if err != nil {
|
||||
writeAccountError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, accountResultPayload(result))
|
||||
}
|
||||
|
||||
func (a *API) accountLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
if a.auth == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return
|
||||
}
|
||||
if err := a.auth.Logout(r); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrAuthRequired):
|
||||
writeError(w, r, http.StatusUnauthorized, 30041, "AUTH_REQUIRED", "authentication required", nil)
|
||||
case errors.Is(err, auth.ErrForbidden):
|
||||
writeError(w, r, http.StatusForbidden, 30003, reasonForbidden, "forbidden", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, nil)
|
||||
}
|
||||
|
||||
func (a *API) validatePublicClient(w http.ResponseWriter, r *http.Request) bool {
|
||||
if a.accounts == nil || a.auth == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return false
|
||||
}
|
||||
if err := a.auth.ValidateAPIKey(r); err != nil {
|
||||
writeError(w, r, http.StatusForbidden, 30003, reasonForbidden, "valid API key required", nil)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func accountResultPayload(result *accountsvc.Result) map[string]any {
|
||||
return map[string]any{
|
||||
"member_id": result.Account.MemberID,
|
||||
"member_no": result.Account.MemberNo,
|
||||
"username": result.Account.Username,
|
||||
"phone_mask": result.Account.MobileMask,
|
||||
"invite_code": result.Account.InviteCode,
|
||||
"token": result.Token,
|
||||
"token_type": "Bearer",
|
||||
"expires_at": result.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
func writeAccountError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, accountsvc.ErrUsernameInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "USERNAME_INVALID_FORMAT", "validation failed", map[string]any{"field": "username"})
|
||||
case errors.Is(err, accountsvc.ErrPhoneInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PHONE_INVALID_FORMAT", "validation failed", map[string]any{"field": "phone"})
|
||||
case errors.Is(err, accountsvc.ErrPasswordInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PASSWORD_INVALID_FORMAT", "validation failed", map[string]any{"field": "password"})
|
||||
case errors.Is(err, accountsvc.ErrReferralInvalid), errors.Is(err, store.ErrReferralInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "REFERRAL_CODE_INVALID", "validation failed", map[string]any{"field": "referral_code"})
|
||||
case errors.Is(err, accountsvc.ErrPrivacyNotAccepted):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PRIVACY_POLICY_NOT_ACCEPTED", "validation failed", map[string]any{"field": "privacy_accepted"})
|
||||
case errors.Is(err, store.ErrAccountExists):
|
||||
writeError(w, r, http.StatusConflict, 30040, "ACCOUNT_ALREADY_REGISTERED", "account already registered", nil)
|
||||
case errors.Is(err, store.ErrInvalidCredentials):
|
||||
writeError(w, r, http.StatusUnauthorized, 30043, "INVALID_CREDENTIALS", "invalid phone or password", nil)
|
||||
case errors.Is(err, accountsvc.ErrRateLimited):
|
||||
writeError(w, r, http.StatusTooManyRequests, 30020, "RATE_LIMITED", "too many login attempts", nil)
|
||||
case errors.Is(err, accountsvc.ErrUnavailable):
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
func (a *API) adminInitializeTenant(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
TenantCode string `json:"tenant_code"`
|
||||
}
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if body.TenantID <= 0 {
|
||||
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_id"})
|
||||
return
|
||||
}
|
||||
st, err := a.store.ForTenant(r.Context(), body.TenantID, strings.TrimSpace(body.TenantCode))
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
result, err := st.InitializeTenantDefaults(r.Context())
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{
|
||||
"tenant_id": body.TenantID,
|
||||
"workbench_apps": result.WorkbenchApps,
|
||||
"attendance_rules": result.AttendanceRules,
|
||||
"attendance_shifts": result.AttendanceShifts,
|
||||
"mailbox_configs": result.MailboxConfigs,
|
||||
"feedback_configs": result.FeedbackConfigs,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) adminWorkbenchApps(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
page := queryInt(r, "page", 1)
|
||||
perPage := queryInt(r, "per_page", 20)
|
||||
var enabled *int
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("enabled")); raw != "" {
|
||||
if value, err := strconv.Atoi(raw); err == nil {
|
||||
enabled = &value
|
||||
}
|
||||
}
|
||||
items, total, err := st.AdminWorkbenchModules(r.Context(), page, perPage, r.URL.Query().Get("code"), r.URL.Query().Get("name"), enabled)
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"items": adminWorkbenchModulePayloads(items), "total": total, "page": page, "per_page": perPage})
|
||||
case http.MethodPost:
|
||||
var body adminWorkbenchModuleRequest
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
item, err := st.UpsertAdminWorkbenchModule(r.Context(), store.AdminWorkbenchModuleInput{
|
||||
ID: body.ID, Code: body.Code, Name: body.Name, IconFileID: body.IconFileID, Route: body.Route,
|
||||
SortOrder: body.SortOrder, UnreadKey: body.UnreadKey, Enabled: body.Enabled, Permission: body.Permission,
|
||||
})
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, adminWorkbenchModulePayload(*item))
|
||||
default:
|
||||
methodNotAllowed(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) adminWorkbenchAppDisable(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if err := st.DisableAdminWorkbenchModule(r.Context(), body.ID, body.Code); err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *API) adminWorkbenchAppsReorder(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Items []struct {
|
||||
Code string `json:"code"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
items := make([]store.AdminWorkbenchReorderItem, 0, len(body.Items))
|
||||
for _, item := range body.Items {
|
||||
items = append(items, store.AdminWorkbenchReorderItem{Code: item.Code, SortOrder: item.SortOrder})
|
||||
}
|
||||
if err := st.ReorderAdminWorkbenchModules(r.Context(), items); err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *API) adminOrganizationDepartments(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
page := queryInt(r, "page", 1)
|
||||
perPage := queryInt(r, "per_page", 20)
|
||||
items, total, err := st.AdminOrganizationDepartments(r.Context(), page, perPage,
|
||||
r.URL.Query().Get("parent_id"), r.URL.Query().Get("name"), r.URL.Query().Get("state"))
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"items": adminOrganizationDepartmentPayloads(items), "total": total, "page": page, "per_page": perPage})
|
||||
}
|
||||
|
||||
func (a *API) adminOrganizationMembers(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
page := queryInt(r, "page", 1)
|
||||
perPage := queryInt(r, "per_page", 20)
|
||||
userID, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("user_id")), 10, 64)
|
||||
items, total, err := st.AdminOrganizationMembers(r.Context(), page, perPage,
|
||||
r.URL.Query().Get("department_id"), firstNonEmpty(r.URL.Query().Get("q"), r.URL.Query().Get("keyword")),
|
||||
r.URL.Query().Get("employment_state"), userID)
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"items": adminOrganizationMemberPayloads(items), "total": total, "page": page, "per_page": perPage})
|
||||
}
|
||||
|
||||
func (a *API) adminMailboxConfig(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
cfg, err := st.AdminMailboxConfig(r.Context())
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, adminMailboxConfigPayload(*cfg))
|
||||
case http.MethodPost:
|
||||
var body adminMailboxConfigRequest
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if err := st.UpdateAdminMailboxConfig(r.Context(), store.AdminMailboxConfig{
|
||||
Enabled: body.Enabled, AllowAnonymous: body.AllowAnonymous, RequiredFields: body.RequiredFields,
|
||||
MaxLength: body.MaxLength, AttachmentPolicy: body.AttachmentPolicy, NoticeTitle: body.NoticeTitle, NoticeContent: body.NoticeContent,
|
||||
}); err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"ok": true})
|
||||
default:
|
||||
methodNotAllowed(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) adminFeedbackConfig(w http.ResponseWriter, r *http.Request) {
|
||||
st, ok := a.internalAdminStore(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
cfg, err := st.AdminFeedbackConfig(r.Context())
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, adminFeedbackConfigPayload(*cfg))
|
||||
case http.MethodPost:
|
||||
var body adminFeedbackConfigRequest
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if err := st.UpdateAdminFeedbackConfig(r.Context(), store.AdminFeedbackConfig{
|
||||
Categories: body.Categories, MaxLength: body.MaxLength, AttachmentPolicy: body.AttachmentPolicy,
|
||||
AllowContact: body.AllowContact, DiagnosticsNotice: body.DiagnosticsNotice,
|
||||
}); err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, map[string]any{"ok": true})
|
||||
default:
|
||||
methodNotAllowed(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) internalAdminStore(w http.ResponseWriter, r *http.Request) (*store.Store, bool) {
|
||||
tenantID, _ := strconv.ParseInt(firstNonEmpty(r.Header.Get("X-Tenant-ID"), r.URL.Query().Get("tenant_id")), 10, 64)
|
||||
tenantCode := firstNonEmpty(r.Header.Get("X-Tenant-Code"), r.URL.Query().Get("tenant_code"), "default")
|
||||
st, err := a.store.ForTenant(r.Context(), tenantID, tenantCode)
|
||||
if err != nil {
|
||||
writeStoreError(w, r, err)
|
||||
return nil, false
|
||||
}
|
||||
return st, true
|
||||
}
|
||||
|
||||
type adminWorkbenchModuleRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
IconFileID string `json:"icon_file_id"`
|
||||
Route string `json:"route"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
UnreadKey string `json:"unread_key"`
|
||||
Enabled int `json:"enabled"`
|
||||
Permission string `json:"permission"`
|
||||
}
|
||||
|
||||
type adminMailboxConfigRequest struct {
|
||||
Enabled int `json:"enabled"`
|
||||
AllowAnonymous int `json:"allow_anonymous"`
|
||||
RequiredFields string `json:"required_fields"`
|
||||
MaxLength int `json:"max_length"`
|
||||
AttachmentPolicy string `json:"attachment_policy"`
|
||||
NoticeTitle string `json:"notice_title"`
|
||||
NoticeContent string `json:"notice_content"`
|
||||
}
|
||||
|
||||
type adminFeedbackConfigRequest struct {
|
||||
Categories string `json:"categories"`
|
||||
MaxLength int `json:"max_length"`
|
||||
AttachmentPolicy string `json:"attachment_policy"`
|
||||
AllowContact int `json:"allow_contact"`
|
||||
DiagnosticsNotice string `json:"diagnostics_notice"`
|
||||
}
|
||||
|
||||
func adminWorkbenchModulePayloads(items []store.AdminWorkbenchModule) []map[string]any {
|
||||
res := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
res = append(res, adminWorkbenchModulePayload(item))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func adminWorkbenchModulePayload(item store.AdminWorkbenchModule) map[string]any {
|
||||
return map[string]any{
|
||||
"id": item.ID,
|
||||
"code": item.Code,
|
||||
"name": item.Name,
|
||||
"iconFileId": item.IconFileID,
|
||||
"route": item.Route,
|
||||
"sortOrder": item.SortOrder,
|
||||
"unreadKey": item.UnreadKey,
|
||||
"enabled": item.Enabled,
|
||||
"permission": item.Permission,
|
||||
"createdAt": formatAdminTime(item.CreatedAt),
|
||||
"updatedAt": formatAdminTime(item.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func adminOrganizationDepartmentPayloads(items []store.OrganizationDepartment) []map[string]any {
|
||||
res := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
res = append(res, map[string]any{
|
||||
"department_id": item.DepartmentID,
|
||||
"name": item.Name,
|
||||
"parent_id": item.ParentID,
|
||||
"child_count": item.ChildCount,
|
||||
"member_count": item.MemberCount,
|
||||
"version": item.Version,
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func adminOrganizationMemberPayloads(items []store.OrganizationMember) []map[string]any {
|
||||
res := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
res = append(res, map[string]any{
|
||||
"user_id": item.UserID,
|
||||
"department_id": item.DepartmentID,
|
||||
"display_name": item.DisplayName,
|
||||
"position": item.Position,
|
||||
"mobile": item.Mobile,
|
||||
"email": item.Email,
|
||||
"avatar_file_id": item.AvatarFileID,
|
||||
"status": item.Status,
|
||||
"contactable": item.Contactable,
|
||||
"version": item.Version,
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func adminMailboxConfigPayload(cfg store.AdminMailboxConfig) map[string]any {
|
||||
return map[string]any{
|
||||
"id": cfg.ID,
|
||||
"enabled": cfg.Enabled,
|
||||
"allowAnonymous": cfg.AllowAnonymous,
|
||||
"requiredFields": cfg.RequiredFields,
|
||||
"maxLength": cfg.MaxLength,
|
||||
"attachmentPolicy": cfg.AttachmentPolicy,
|
||||
"noticeTitle": cfg.NoticeTitle,
|
||||
"noticeContent": cfg.NoticeContent,
|
||||
"noticeUpdatedAt": formatAdminTime(cfg.NoticeUpdatedAt),
|
||||
"updatedAt": formatAdminTime(cfg.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func adminFeedbackConfigPayload(cfg store.AdminFeedbackConfig) map[string]any {
|
||||
return map[string]any{
|
||||
"id": cfg.ID,
|
||||
"categories": cfg.Categories,
|
||||
"maxLength": cfg.MaxLength,
|
||||
"attachmentPolicy": cfg.AttachmentPolicy,
|
||||
"allowContact": cfg.AllowContact,
|
||||
"diagnosticsNotice": cfg.DiagnosticsNotice,
|
||||
"updatedAt": formatAdminTime(cfg.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, out any) bool {
|
||||
if err := json.NewDecoder(r.Body).Decode(out); err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func queryInt(r *http.Request, key string, fallback int) int {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get(key)))
|
||||
if err != nil || value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func formatAdminTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,254 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/managerconfig"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
aliyunoss "github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||
)
|
||||
|
||||
const aiTableExportDocxMIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
|
||||
type aiExportedFile struct {
|
||||
FileID string
|
||||
FileName string
|
||||
DownloadURL string
|
||||
}
|
||||
|
||||
type ossUploadConfig struct {
|
||||
Enabled bool
|
||||
Endpoint string
|
||||
Bucket string
|
||||
AccessKeyID string
|
||||
AccessKeySecret string
|
||||
ServeURL string
|
||||
CacheControl string
|
||||
}
|
||||
|
||||
func (a *API) uploadAITableAnalysisExport(ctx context.Context, r *http.Request, st *store.Store, p *auth.Principal, item *store.AITableAnalysis) (*aiExportedFile, error) {
|
||||
cfg, err := a.ossUploadConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := aiTableAnalysisDocx(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileID := newFileUploadID()
|
||||
publicID, storageID := formatFileUploadID(fileID)
|
||||
createdAt := time.Now().UTC()
|
||||
objectKey := path.Join("tenants", strconv.FormatInt(p.TenantID, 10), createdAt.Format("2006"), createdAt.Format("01"), storageID)
|
||||
client, err := aliyunoss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bucket, err := client.Bucket(cfg.Bucket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options := []aliyunoss.Option{aliyunoss.ContentType(aiTableExportDocxMIME)}
|
||||
if cfg.CacheControl != "" {
|
||||
options = append(options, aliyunoss.CacheControl(cfg.CacheControl))
|
||||
}
|
||||
if err := bucket.PutObject(objectKey, bytes.NewReader(data), options...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
etag := ""
|
||||
if meta, err := bucket.GetObjectDetailedMeta(objectKey); err == nil {
|
||||
etag = strings.Trim(meta.Get("ETag"), `"`)
|
||||
}
|
||||
fileName := aiTableExportFileName(item)
|
||||
fileURLName := publicID + ".docx"
|
||||
if err := st.SaveCompletedFileUpload(ctx, store.CompletedFileUpload{
|
||||
ID: fileID, UserID: p.UserID, MIMEType: aiTableExportDocxMIME, Size: int64(len(data)),
|
||||
ETag: etag, Location: objectKey, CreatedAt: createdAt,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := st.UpdateAITableAnalysisExportFileID(ctx, p.UserID, item.RecordID, fileURLName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &aiExportedFile{
|
||||
FileID: fileURLName,
|
||||
FileName: fileName,
|
||||
DownloadURL: publicFileDownloadURL(r, cfg.ServeURL, fileURLName),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func downloadWorkbenchFileToTemp(r *http.Request, fileID, fileType string) (string, func(), error) {
|
||||
fileID = strings.TrimSpace(fileID)
|
||||
if fileID == "" {
|
||||
return "", func() {}, fmt.Errorf("empty file id")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, absoluteURL(r, "/v0/file/s/"+url.PathEscape(fileID)), nil)
|
||||
if err != nil {
|
||||
return "", func() {}, err
|
||||
}
|
||||
copyHeader(req, r, "Authorization")
|
||||
copyHeader(req, r, "X-MeetingIM-APIKey")
|
||||
copyHeader(req, r, "X-Tenant-Code")
|
||||
copyHeader(req, r, "X-IM-Schema")
|
||||
copyHeader(req, r, "X-Request-ID")
|
||||
if auth := strings.TrimSpace(r.Header.Get("X-MeetingIM-Auth")); auth != "" {
|
||||
req.Header.Set("X-MeetingIM-Auth", auth)
|
||||
} else if token := bearerTokenFromHeader(r.Header.Get("Authorization")); token != "" {
|
||||
req.Header.Set("X-MeetingIM-Auth", "token "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", func() {}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", func() {}, fmt.Errorf("file service returned %d", resp.StatusCode)
|
||||
}
|
||||
return responseBodyToTemp(resp.Body, fileType)
|
||||
}
|
||||
|
||||
func (a *API) downloadOSSObjectToTemp(ctx context.Context, objectKey, fileType string) (string, func(), error) {
|
||||
objectKey = strings.Trim(strings.TrimSpace(objectKey), "/")
|
||||
if objectKey == "" {
|
||||
return "", func() {}, fmt.Errorf("empty object key")
|
||||
}
|
||||
cfg, err := a.ossUploadConfig(ctx)
|
||||
if err != nil {
|
||||
return "", func() {}, err
|
||||
}
|
||||
client, err := aliyunoss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
|
||||
if err != nil {
|
||||
return "", func() {}, err
|
||||
}
|
||||
bucket, err := client.Bucket(cfg.Bucket)
|
||||
if err != nil {
|
||||
return "", func() {}, err
|
||||
}
|
||||
reader, err := bucket.GetObject(objectKey)
|
||||
if err != nil {
|
||||
return "", func() {}, err
|
||||
}
|
||||
defer reader.Close()
|
||||
return responseBodyToTemp(reader, fileType)
|
||||
}
|
||||
|
||||
func responseBodyToTemp(reader io.Reader, fileType string) (string, func(), error) {
|
||||
suffix := "." + strings.TrimLeft(strings.ToLower(strings.TrimSpace(fileType)), ".")
|
||||
if suffix == "." {
|
||||
suffix = ".table"
|
||||
}
|
||||
tmp, err := os.CreateTemp("", "im-workbench-ai-table-*"+suffix)
|
||||
if err != nil {
|
||||
return "", func() {}, err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
cleanup := func() { _ = os.Remove(tmpPath) }
|
||||
if _, err := io.Copy(tmp, reader); err != nil {
|
||||
_ = tmp.Close()
|
||||
cleanup()
|
||||
return "", func() {}, err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
cleanup()
|
||||
return "", func() {}, err
|
||||
}
|
||||
return tmpPath, cleanup, nil
|
||||
}
|
||||
|
||||
func copyHeader(dst *http.Request, src *http.Request, key string) {
|
||||
if value := strings.TrimSpace(src.Header.Get(key)); value != "" {
|
||||
dst.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
func bearerTokenFromHeader(value string) string {
|
||||
token, ok := strings.CutPrefix(strings.TrimSpace(value), "Bearer ")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(token)
|
||||
}
|
||||
|
||||
func (a *API) ossUploadConfig(ctx context.Context) (ossUploadConfig, error) {
|
||||
values, err := managerconfig.New(a.manager).ThirdPartyConfig(ctx, "im_third_party",
|
||||
"oss_enabled",
|
||||
"oss_endpoint",
|
||||
"oss_bucket",
|
||||
"oss_access_key_id",
|
||||
"oss_access_key_secret",
|
||||
"oss_serve_url",
|
||||
"oss_cache_control",
|
||||
)
|
||||
if err != nil {
|
||||
return ossUploadConfig{}, err
|
||||
}
|
||||
cfg := ossUploadConfig{
|
||||
Enabled: parseConfigBool(values["oss_enabled"]),
|
||||
Endpoint: strings.TrimSpace(values["oss_endpoint"]),
|
||||
Bucket: strings.TrimSpace(values["oss_bucket"]),
|
||||
AccessKeyID: strings.TrimSpace(values["oss_access_key_id"]),
|
||||
AccessKeySecret: strings.TrimSpace(values["oss_access_key_secret"]),
|
||||
ServeURL: strings.TrimSpace(values["oss_serve_url"]),
|
||||
CacheControl: strings.TrimSpace(values["oss_cache_control"]),
|
||||
}
|
||||
if cfg.ServeURL == "" {
|
||||
cfg.ServeURL = "/v0/file/s/"
|
||||
}
|
||||
if !cfg.Enabled || cfg.Endpoint == "" || cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" {
|
||||
return ossUploadConfig{}, fmt.Errorf("oss config incomplete")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func parseConfigBool(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on", "enabled":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func newFileUploadID() int64 {
|
||||
var random [8]byte
|
||||
if _, err := rand.Read(random[:]); err != nil {
|
||||
return time.Now().UnixNano()
|
||||
}
|
||||
suffix := int64(binary.LittleEndian.Uint64(random[:]) & ((1 << 20) - 1))
|
||||
return (time.Now().UnixMilli() << 20) | suffix
|
||||
}
|
||||
|
||||
func formatFileUploadID(id int64) (publicID string, storageID string) {
|
||||
encrypted := encryptUID(id)
|
||||
publicID = base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(encrypted)
|
||||
storageID = strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(encrypted))
|
||||
return publicID, storageID
|
||||
}
|
||||
|
||||
func publicFileDownloadURL(r *http.Request, serveURL, fileName string) string {
|
||||
serveURL = strings.TrimSpace(serveURL)
|
||||
if strings.HasPrefix(serveURL, "http://") || strings.HasPrefix(serveURL, "https://") {
|
||||
return strings.TrimRight(serveURL, "/") + "/" + fileName
|
||||
}
|
||||
if serveURL == "" {
|
||||
serveURL = "/v0/file/s/"
|
||||
}
|
||||
if !strings.HasPrefix(serveURL, "/") {
|
||||
serveURL = "/" + serveURL
|
||||
}
|
||||
return absoluteURL(r, strings.TrimRight(serveURL, "/")+"/"+fileName)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
func TestAITableAnalysisDocx(t *testing.T) {
|
||||
docx, err := aiTableAnalysisDocx(&store.AITableAnalysis{
|
||||
RecordID: "airec_tbl_test",
|
||||
FileName: "sales.xlsx",
|
||||
FileType: "xlsx",
|
||||
FileSize: 1234,
|
||||
Topic: "销售数据",
|
||||
MetricsJSON: `[{"name":"销售额","value":"12万","change_text":"+10%","trend":"up"}]`,
|
||||
AlertsJSON: `[{"level":"warning","title":"异常波动","description":"订单量下降"}]`,
|
||||
Interpretation: "整体销售额增长。",
|
||||
CreatedAt: time.Date(2026, 9, 29, 8, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(docx), int64(len(docx)))
|
||||
if err != nil {
|
||||
t.Fatalf("docx is not a zip: %v", err)
|
||||
}
|
||||
var document string
|
||||
for _, file := range zr.File {
|
||||
if file.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(rc)
|
||||
_ = rc.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
document = string(data)
|
||||
break
|
||||
}
|
||||
for _, want := range []string{"销售数据", "销售额", "异常波动", "整体销售额增长"} {
|
||||
if !strings.Contains(document, want) {
|
||||
t.Fatalf("document.xml missing %q: %s", want, document)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/ai"
|
||||
"git.118im.com/im_team/im_workbench/internal/events"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
aiWorkerCount = 2
|
||||
aiWorkerPollInterval = time.Second
|
||||
aiTaskRunTimeout = 3 * time.Minute
|
||||
)
|
||||
|
||||
type aiDocumentTaskSnapshot struct {
|
||||
SourceText string `json:"source_text"`
|
||||
TitleHint string `json:"title_hint"`
|
||||
DocumentType string `json:"document_type"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
type aiTableTaskSnapshot struct {
|
||||
FileID string `json:"file_id"`
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
Topic string `json:"topic"`
|
||||
SheetName string `json:"sheet_name"`
|
||||
Language string `json:"language"`
|
||||
RecordID string `json:"record_id"`
|
||||
}
|
||||
|
||||
type aiMeetingSummaryTaskSnapshot struct {
|
||||
MeetingID string `json:"meeting_id"`
|
||||
SummaryID string `json:"summary_id"`
|
||||
FileIDs []string `json:"file_ids"`
|
||||
SummaryType string `json:"summary_type"`
|
||||
ForceRegenerate bool `json:"force_regenerate"`
|
||||
}
|
||||
|
||||
func (a *API) StartAIWorkers(ctx context.Context) {
|
||||
if a == nil || a.store == nil || a.ai == nil {
|
||||
return
|
||||
}
|
||||
for i := 0; i < aiWorkerCount; i++ {
|
||||
go a.aiWorkerLoop(ctx, i+1)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) aiWorkerLoop(ctx context.Context, workerID int) {
|
||||
ticker := time.NewTicker(aiWorkerPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
task, err := a.store.ClaimQueuedAITask(ctx)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("ai worker claim failed: worker=%d err=%v", workerID, err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
continue
|
||||
}
|
||||
}
|
||||
a.runAITask(ctx, task)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) runAITask(parent context.Context, task *store.AITask) {
|
||||
ctx, cancel := context.WithTimeout(parent, aiTaskRunTimeout)
|
||||
defer cancel()
|
||||
st, err := a.store.ForTenant(ctx, task.TenantID, task.TenantCode)
|
||||
if err != nil {
|
||||
log.Printf("ai task tenant store failed: task_id=%s tenant_id=%d err=%v", task.TaskID, task.TenantID, err)
|
||||
return
|
||||
}
|
||||
switch task.Feature {
|
||||
case "smart_document":
|
||||
a.runAIDocumentTask(ctx, st, task)
|
||||
case "smart_table":
|
||||
a.runAITableTask(ctx, st, task)
|
||||
case "meeting_summary":
|
||||
a.runAIMeetingSummaryTask(ctx, st, task)
|
||||
default:
|
||||
a.finishAITaskFailure(ctx, st, task, "AI_TASK_UNSUPPORTED", "unsupported ai task feature")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) runAIDocumentTask(ctx context.Context, st *store.Store, task *store.AITask) {
|
||||
var input aiDocumentTaskSnapshot
|
||||
if !decodeTaskSnapshot(task, &input) {
|
||||
a.finishAITaskFailure(ctx, st, task, reasonMalformedRequest, "invalid task input")
|
||||
return
|
||||
}
|
||||
result, err := a.ai.GenerateDocument(ctx, strings.TrimSpace(input.SourceText), strings.TrimSpace(input.TitleHint), strings.TrimSpace(input.DocumentType), strings.TrimSpace(input.Language))
|
||||
if err != nil {
|
||||
a.finishAITaskAIError(ctx, st, task, err, hashText(input.SourceText))
|
||||
return
|
||||
}
|
||||
taskResult := map[string]any{
|
||||
"title": result.Title,
|
||||
"summary": result.Summary,
|
||||
"content_markdown": result.ContentMarkdown,
|
||||
"content_plain": result.ContentPlain,
|
||||
"sections": result.Sections,
|
||||
"source_excerpt": excerpt(input.SourceText, 200),
|
||||
"can_save": true,
|
||||
}
|
||||
ok, err := st.FinishAITaskResult(ctx, task.TaskID, "succeeded", 100, "", taskResult, "", "")
|
||||
if err != nil {
|
||||
log.Printf("ai document task finish failed: task_id=%s err=%v", task.TaskID, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID := taskUserID(task)
|
||||
_ = st.InsertAIUsageLog(ctx, store.AIUsageLog{
|
||||
TenantCode: task.TenantCode, UserID: task.UserID, Feature: "smart_document", TaskID: task.TaskID,
|
||||
Provider: "deepseek", Model: result.Usage.Model, ProviderRequestID: result.Usage.ProviderRequestID,
|
||||
PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens,
|
||||
LatencyMS: int(result.Usage.LatencyMS), Status: "succeeded", InputHash: hashText(input.SourceText), OutputHash: hashText(result.ContentMarkdown),
|
||||
})
|
||||
a.publishAITaskEvent(ctx, task.TenantID, task.TenantCode, userID, task.TaskID, "smart_document", result.Title, result.Summary, map[string]any{
|
||||
"task_id": task.TaskID, "feature": "smart_document", "status": "succeeded", "progress": 100, "result_type": "text",
|
||||
"result": compactAITextResult(result.Title, result.Summary, result.ContentMarkdown, result.ContentPlain),
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) runAITableTask(ctx context.Context, st *store.Store, task *store.AITask) {
|
||||
var input aiTableTaskSnapshot
|
||||
if !decodeTaskSnapshot(task, &input) {
|
||||
a.finishAITaskFailure(ctx, st, task, reasonMalformedRequest, "invalid task input")
|
||||
return
|
||||
}
|
||||
fileType := strings.ToLower(strings.TrimSpace(input.FileType))
|
||||
fileMeta := st.AIFileMetadata(ctx, input.FileID)
|
||||
profile := a.tableProfileForAIContext(ctx, input.FileID, fileMeta, fileType)
|
||||
result, err := a.ai.AnalyzeTable(ctx, ai.TableAnalysisInput{
|
||||
Topic: input.Topic, FileID: input.FileID, FileName: input.FileName, FileType: fileType, FileSize: input.FileSize, Profile: profile,
|
||||
})
|
||||
if err != nil {
|
||||
a.finishAITaskAIError(ctx, st, task, err, hashText(input.FileID+":"+input.Topic))
|
||||
return
|
||||
}
|
||||
recordID := strings.TrimSpace(input.RecordID)
|
||||
if recordID == "" {
|
||||
recordID = "airec_tbl_" + randomToken(10)
|
||||
}
|
||||
if err := st.SaveAITableAnalysis(ctx, task.TenantCode, taskUserID(task), store.AIRecord{
|
||||
RecordID: recordID, RecordType: "smart_table", Title: result.Title, Summary: excerpt(firstNonEmpty(result.Summary, result.Interpretation), 160),
|
||||
Status: "succeeded", TaskID: task.TaskID,
|
||||
}, store.AITableAnalysis{
|
||||
RecordID: recordID, FileID: input.FileID, FileName: input.FileName, FileType: fileType, FileSize: input.FileSize,
|
||||
Topic: input.Topic, MetricsJSON: mustJSON(result.Metrics, "[]"), PreviewJSON: mustJSON(result.Preview, "{}"),
|
||||
AlertsJSON: mustJSON(result.Alerts, "[]"), Interpretation: result.Interpretation,
|
||||
PromptVersion: "smart_table_v1", Model: result.Usage.Model,
|
||||
}); err != nil {
|
||||
a.finishAITaskFailure(ctx, st, task, reasonAITaskFailed, err.Error())
|
||||
return
|
||||
}
|
||||
taskResult := map[string]any{"record_id": recordID, "title": result.Title, "metrics": result.Metrics, "alerts": result.Alerts, "interpretation": result.Interpretation}
|
||||
ok, err := st.FinishAITaskResult(ctx, task.TaskID, "succeeded", 100, recordID, taskResult, "", "")
|
||||
if err != nil {
|
||||
log.Printf("ai table task finish failed: task_id=%s err=%v", task.TaskID, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = st.InsertAIUsageLog(ctx, store.AIUsageLog{
|
||||
TenantCode: task.TenantCode, UserID: task.UserID, Feature: "smart_table", TaskID: task.TaskID,
|
||||
Provider: "deepseek", Model: result.Usage.Model, ProviderRequestID: result.Usage.ProviderRequestID,
|
||||
PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens,
|
||||
LatencyMS: int(result.Usage.LatencyMS), Status: "succeeded", InputHash: hashText(input.FileID + ":" + input.Topic), OutputHash: hashText(result.Interpretation),
|
||||
})
|
||||
a.publishAITaskEvent(ctx, task.TenantID, task.TenantCode, taskUserID(task), task.TaskID, "smart_table", result.Title, result.Summary, map[string]any{
|
||||
"task_id": task.TaskID, "feature": "smart_table", "status": "succeeded", "progress": 100, "result_type": "record",
|
||||
"record_id": recordID,
|
||||
"result": map[string]any{"record_id": recordID, "title": result.Title, "summary": excerpt(firstNonEmpty(result.Summary, result.Interpretation), 160)},
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) runAIMeetingSummaryTask(ctx context.Context, st *store.Store, task *store.AITask) {
|
||||
var input aiMeetingSummaryTaskSnapshot
|
||||
if !decodeTaskSnapshot(task, &input) {
|
||||
a.finishAITaskFailure(ctx, st, task, reasonMalformedRequest, "invalid task input")
|
||||
return
|
||||
}
|
||||
userID := taskUserID(task)
|
||||
meeting, err := st.Meeting(ctx, userID, input.MeetingID)
|
||||
if err != nil {
|
||||
a.finishAITaskFailure(ctx, st, task, reasonAITaskFailed, err.Error())
|
||||
return
|
||||
}
|
||||
files, err := st.MeetingNoteFiles(ctx, userID, input.MeetingID)
|
||||
if err != nil {
|
||||
a.finishAITaskFailure(ctx, st, task, reasonAITaskFailed, err.Error())
|
||||
return
|
||||
}
|
||||
fileNames, fileIDs := meetingSummaryFiles(files, input.FileIDs)
|
||||
result, err := a.ai.SummarizeMeeting(ctx, ai.MeetingSummaryInput{
|
||||
MeetingTitle: meeting.Title, StartAt: meeting.StartAt.UTC().Format(time.RFC3339Nano), EndAt: meeting.EndAt.UTC().Format(time.RFC3339Nano),
|
||||
Participants: meetingSummaryParticipants(*meeting), Files: fileNames,
|
||||
})
|
||||
if err != nil {
|
||||
a.finishAITaskAIError(ctx, st, task, err, hashText(input.MeetingID+strings.Join(fileIDs, ",")))
|
||||
return
|
||||
}
|
||||
summaryID := strings.TrimSpace(input.SummaryID)
|
||||
if summaryID == "" {
|
||||
summaryID = "aisum_" + randomToken(10)
|
||||
}
|
||||
if err := st.UpsertAIMeetingSummary(ctx, task.TenantCode, userID, store.AIMeetingSummary{
|
||||
SummaryID: summaryID, MeetingID: input.MeetingID, TaskID: task.TaskID,
|
||||
ConclusionsJSON: mustJSON(result.Conclusions, "[]"), DecisionsJSON: mustJSON(result.Decisions, "[]"),
|
||||
ActionItemsJSON: mustJSON(result.ActionItems, "[]"), SourceFileIDsJSON: mustJSON(fileIDs, "[]"),
|
||||
PromptVersion: "meeting_summary_v1", Model: result.Usage.Model,
|
||||
}); err != nil {
|
||||
a.finishAITaskFailure(ctx, st, task, reasonAITaskFailed, err.Error())
|
||||
return
|
||||
}
|
||||
taskResult := map[string]any{
|
||||
"summary_id": summaryID, "meeting_id": input.MeetingID,
|
||||
"conclusions": result.Conclusions, "decisions": result.Decisions, "action_items": result.ActionItems,
|
||||
}
|
||||
ok, err := st.FinishAITaskResult(ctx, task.TaskID, "succeeded", 100, summaryID, taskResult, "", "")
|
||||
if err != nil {
|
||||
log.Printf("ai meeting summary task finish failed: task_id=%s err=%v", task.TaskID, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = st.InsertAIUsageLog(ctx, store.AIUsageLog{
|
||||
TenantCode: task.TenantCode, UserID: task.UserID, Feature: "meeting_summary", TaskID: task.TaskID,
|
||||
Provider: "deepseek", Model: result.Usage.Model, ProviderRequestID: result.Usage.ProviderRequestID,
|
||||
PromptTokens: result.Usage.PromptTokens, CompletionTokens: result.Usage.CompletionTokens, TotalTokens: result.Usage.TotalTokens,
|
||||
LatencyMS: int(result.Usage.LatencyMS), Status: "succeeded", InputHash: hashText(input.MeetingID + strings.Join(fileIDs, ",")), OutputHash: hashText(mustJSON(result.Conclusions, "[]")),
|
||||
})
|
||||
a.publishAITaskEvent(ctx, task.TenantID, task.TenantCode, userID, task.TaskID, "meeting_summary", meeting.Title, "", map[string]any{
|
||||
"task_id": task.TaskID, "feature": "meeting_summary", "status": "succeeded", "progress": 100, "result_type": "summary",
|
||||
"meeting_id": input.MeetingID, "summary_id": summaryID,
|
||||
"result": map[string]any{"meeting_id": input.MeetingID, "summary_id": summaryID, "conclusions": result.Conclusions, "decisions": result.Decisions, "action_items": result.ActionItems},
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) finishAITaskAIError(ctx context.Context, st *store.Store, task *store.AITask, err error, inputHash string) {
|
||||
reason := aiErrorReason(err)
|
||||
a.finishAITaskFailure(ctx, st, task, reason, err.Error())
|
||||
_ = st.InsertAIUsageLog(ctx, store.AIUsageLog{
|
||||
TenantCode: task.TenantCode, UserID: task.UserID, Feature: task.Feature, TaskID: task.TaskID,
|
||||
Provider: "deepseek", Status: "failed", ErrorReason: reason, InputHash: inputHash,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) finishAITaskFailure(ctx context.Context, st *store.Store, task *store.AITask, reason, message string) {
|
||||
ok, err := st.FinishAITaskResult(ctx, task.TaskID, "failed", 100, "", nil, reason, message)
|
||||
if err != nil {
|
||||
log.Printf("ai task failure update failed: task_id=%s reason=%s err=%v", task.TaskID, reason, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.publishAITaskEvent(ctx, task.TenantID, task.TenantCode, taskUserID(task), task.TaskID, task.Feature, "AI 任务失败", message, map[string]any{
|
||||
"task_id": task.TaskID, "feature": task.Feature, "status": "failed", "progress": 100, "result_type": "none",
|
||||
"error": map[string]any{"reason": reason, "message": message},
|
||||
"updated_at": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) publishAITaskEvent(ctx context.Context, tenantID int64, tenantCode string, userID int64, taskID, feature, title, summary string, data map[string]any) {
|
||||
if a == nil || a.events == nil || userID <= 0 || strings.TrimSpace(taskID) == "" {
|
||||
return
|
||||
}
|
||||
event := events.Event{
|
||||
EventID: events.StableEventID(tenantID, "ai", "workbench.ai.task.updated", taskID+":"+stringValue(data["status"]), userID),
|
||||
TenantID: tenantID,
|
||||
TenantCode: tenantCode,
|
||||
ActorUserID: userID,
|
||||
Module: "ai",
|
||||
Action: "workbench.ai.task.updated",
|
||||
ResourceID: taskID,
|
||||
TargetUserIDs: []int64{userID},
|
||||
Title: strings.TrimSpace(title),
|
||||
Summary: strings.TrimSpace(summary),
|
||||
OccurredAt: time.Now().UTC().Round(time.Millisecond),
|
||||
Data: data,
|
||||
}
|
||||
pubCtx, cancel := context.WithTimeout(ctx, workbenchEventPublishTimeout)
|
||||
defer cancel()
|
||||
if err := a.events.Publish(pubCtx, event); err != nil {
|
||||
log.Printf("ai task event publish failed: event_id=%s task_id=%s feature=%s err=%v", event.EventID, taskID, feature, err)
|
||||
}
|
||||
}
|
||||
|
||||
func aiTaskSnapshot(value any) (sql.NullString, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return sql.NullString{}, err
|
||||
}
|
||||
return sql.NullString{String: string(data), Valid: true}, nil
|
||||
}
|
||||
|
||||
func decodeTaskSnapshot(task *store.AITask, dst any) bool {
|
||||
if task == nil || !task.InputSnapshot.Valid || strings.TrimSpace(task.InputSnapshot.String) == "" {
|
||||
return false
|
||||
}
|
||||
return json.Unmarshal([]byte(task.InputSnapshot.String), dst) == nil
|
||||
}
|
||||
|
||||
func (a *API) tableProfileForAIContext(ctx context.Context, fileID string, meta store.AIFileMetadata, fileType string) ai.TableProfile {
|
||||
if path := resolveTableFilePath(fileID, meta.Location); path != "" {
|
||||
return tableProfileFromPath(path, fileType)
|
||||
}
|
||||
if strings.TrimSpace(meta.Location) == "" {
|
||||
return ai.TableProfile{Parsed: false, Message: "source file metadata is not available"}
|
||||
}
|
||||
path, cleanup, err := a.downloadOSSObjectToTemp(ctx, meta.Location, fileType)
|
||||
if err != nil {
|
||||
return ai.TableProfile{Parsed: false, Message: "source file is not available: " + err.Error()}
|
||||
}
|
||||
defer cleanup()
|
||||
return tableProfileFromPath(path, fileType)
|
||||
}
|
||||
|
||||
func taskUserID(task *store.AITask) int64 {
|
||||
if task == nil {
|
||||
return 0
|
||||
}
|
||||
userID, _ := strconv.ParseInt(strings.TrimSpace(task.UserID), 10, 64)
|
||||
return userID
|
||||
}
|
||||
|
||||
func compactAITextResult(title, summary, markdown, plain string) map[string]any {
|
||||
result := map[string]any{"title": title, "summary": summary, "content_plain": plain}
|
||||
if len([]rune(markdown)) <= 8000 {
|
||||
result["content_markdown"] = markdown
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
text, _ := value.(string)
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
+37
-1166
File diff suppressed because it is too large
Load Diff
@@ -1,40 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
func TestAttendanceCheckinPayloadReturnsJSONObjectFields(t *testing.T) {
|
||||
now := time.Date(2026, 9, 18, 1, 27, 49, 0, time.UTC)
|
||||
payload := attendanceCheckinPayload(store.AttendanceCheckin{
|
||||
CheckinID: "attc_1",
|
||||
Action: "check_in",
|
||||
ClientTime: now,
|
||||
ServerTime: now,
|
||||
Result: "normal",
|
||||
Location: `{"name":"当前位置","latitude":22.929670335639365}`,
|
||||
Wifi: `{}`,
|
||||
Device: `{"platform":"ios","device_id":"C7A76C6A-FC57-4CED-B1C1-CE5199944F8C"}`,
|
||||
})
|
||||
|
||||
location, ok := payload["location"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("location=%T, want map[string]any", payload["location"])
|
||||
}
|
||||
if location["name"] != "当前位置" {
|
||||
t.Fatalf("location=%v", location)
|
||||
}
|
||||
if _, ok := payload["wifi"].(map[string]any); !ok {
|
||||
t.Fatalf("wifi=%T, want map[string]any", payload["wifi"])
|
||||
}
|
||||
device, ok := payload["device"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("device=%T, want map[string]any", payload["device"])
|
||||
}
|
||||
if device["platform"] != "ios" {
|
||||
t.Fatalf("device=%v", device)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrConflict = errors.New("conflict")
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/finance"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodGet, http.MethodPost)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateFinanceMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
result, err := a.finance.PaymentAccounts(r.Context(), principal.TenantID, principal.MemberID)
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
return
|
||||
}
|
||||
var body finance.PaymentAccountInput
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.finance.CreatePaymentAccount(r.Context(), principal.TenantID, principal.MemberID, body)
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) withdrawalQuote(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodPost)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateFinanceMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body finance.QuoteInput
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.finance.Quote(r.Context(), principal.TenantID, principal.MemberID, body)
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) withdrawalOrders(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodGet, http.MethodPost)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateFinanceMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
page, ok := optionalPositiveInt(w, r, "page")
|
||||
if !ok {
|
||||
writeFinanceError(w, r, finance.ErrPageInvalid)
|
||||
return
|
||||
}
|
||||
pageSize, ok := optionalPositiveInt(w, r, "page_size")
|
||||
if !ok {
|
||||
writeFinanceError(w, r, finance.ErrPageSizeInvalid)
|
||||
return
|
||||
}
|
||||
result, err := a.finance.WithdrawalOrders(r.Context(), principal.TenantID, principal.MemberID,
|
||||
page, pageSize, r.URL.Query().Get("status"))
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
return
|
||||
}
|
||||
var body finance.CreateWithdrawalInput
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.finance.CreateWithdrawal(r.Context(), principal.TenantID, principal.MemberID,
|
||||
r.Header.Get("Idempotency-Key"), body)
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) withdrawalOrder(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
orderIDText := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/withdrawal-orders/"), "/")
|
||||
if orderIDText == "" || strings.Contains(orderIDText, "/") {
|
||||
writeFinanceError(w, r, finance.ErrOrderIDInvalid)
|
||||
return
|
||||
}
|
||||
orderID, err := strconv.ParseInt(orderIDText, 10, 64)
|
||||
if err != nil || orderID <= 0 {
|
||||
writeFinanceError(w, r, finance.ErrOrderIDInvalid)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateFinanceMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := a.finance.WithdrawalOrder(r.Context(), principal.TenantID, principal.MemberID, orderID)
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) authenticateFinanceMember(w http.ResponseWriter, r *http.Request) (*auth.Principal, bool) {
|
||||
if a.finance == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return nil, false
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return principal, true
|
||||
}
|
||||
|
||||
func writeFinanceError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, finance.ErrAccountTypeInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "ACCOUNT_TYPE_INVALID", "validation failed", map[string]any{"field": "account_type"})
|
||||
case errors.Is(err, finance.ErrAccountFieldsInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAYMENT_ACCOUNT_FIELDS_INVALID", "validation failed", nil)
|
||||
case errors.Is(err, finance.ErrTradePassword):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "TRADE_PASSWORD_INVALID", "trade password is invalid", map[string]any{"field": "trade_password"})
|
||||
case errors.Is(err, finance.ErrTradePasswordUnset):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "TRADE_PASSWORD_NOT_SET", "trade password is not set", nil)
|
||||
case errors.Is(err, finance.ErrRateLimited):
|
||||
writeError(w, r, http.StatusTooManyRequests, 30020, "RATE_LIMITED", "too many trade password attempts", map[string]any{"retry_after_seconds": 900})
|
||||
case errors.Is(err, finance.ErrCurrencyInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "CURRENCY_INVALID", "validation failed", map[string]any{"field": "currency"})
|
||||
case errors.Is(err, finance.ErrAmountInvalid), errors.Is(err, store.ErrWithdrawalAmount):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_AMOUNT_INVALID", "withdrawal amount is outside the allowed range", nil)
|
||||
case errors.Is(err, finance.ErrIdempotencyKey):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "IDEMPOTENCY_KEY_INVALID", "valid Idempotency-Key header is required", map[string]any{"field": "Idempotency-Key"})
|
||||
case errors.Is(err, finance.ErrPageInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"})
|
||||
case errors.Is(err, finance.ErrPageSizeInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"})
|
||||
case errors.Is(err, finance.ErrStatusInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "WITHDRAWAL_STATUS_INVALID", "validation failed", map[string]any{"field": "status"})
|
||||
case errors.Is(err, finance.ErrOrderIDInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "WITHDRAWAL_ORDER_ID_INVALID", "validation failed", map[string]any{"field": "order_id"})
|
||||
case errors.Is(err, store.ErrPaymentAccountExists):
|
||||
writeError(w, r, http.StatusConflict, 30040, "PAYMENT_ACCOUNT_EXISTS", "payment account already exists", nil)
|
||||
case errors.Is(err, store.ErrPaymentAccountInvalid):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "PAYMENT_ACCOUNT_INVALID", "payment account is unavailable or unverified", nil)
|
||||
case errors.Is(err, store.ErrWithdrawalDisabled):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_DISABLED", "withdrawal feature is disabled", nil)
|
||||
case errors.Is(err, store.ErrWithdrawalChannel):
|
||||
writeError(w, r, http.StatusServiceUnavailable, 30053, "WITHDRAWAL_CHANNEL_UNAVAILABLE", "withdrawal channel is unavailable", nil)
|
||||
case errors.Is(err, store.ErrWithdrawalLimit):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_DAILY_LIMIT_REACHED", "daily withdrawal limit reached", nil)
|
||||
case errors.Is(err, store.ErrInsufficientBalance):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "INSUFFICIENT_BALANCE", "insufficient available balance", nil)
|
||||
case errors.Is(err, store.ErrAccountRestricted):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "ACCOUNT_RESTRICTED", "account is restricted", nil)
|
||||
case errors.Is(err, store.ErrAccountNotFound):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "ACCOUNT_RESTRICTED", "account is restricted", nil)
|
||||
case errors.Is(err, store.ErrWithdrawalNotFound):
|
||||
writeError(w, r, http.StatusNotFound, 30044, "WITHDRAWAL_ORDER_NOT_FOUND", "withdrawal order not found", nil)
|
||||
case errors.Is(err, store.ErrIdempotencyConflict):
|
||||
writeError(w, r, http.StatusConflict, 30040, "IDEMPOTENCY_CONFLICT", "Idempotency-Key was reused with different request data", nil)
|
||||
case errors.Is(err, store.ErrAttachmentInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "QR_ATTACHMENT_INVALID", "validation failed", map[string]any{"field": "qr_attachment_id"})
|
||||
case errors.Is(err, store.ErrConcurrentUpdate):
|
||||
writeError(w, r, http.StatusConflict, 30040, "ACCOUNT_CHANGED", "account changed, retry the operation", nil)
|
||||
case errors.Is(err, finance.ErrUnavailable):
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const internalSignatureWindow = 5 * time.Minute
|
||||
|
||||
func (a *API) withInternalAdminAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20))
|
||||
if err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil)
|
||||
return
|
||||
}
|
||||
_ = r.Body.Close()
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
if err := a.verifyInternalAdminSignature(r, body); err != nil {
|
||||
writeError(w, r, http.StatusForbidden, 30041, reasonForbidden, "forbidden", nil)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) verifyInternalAdminSignature(r *http.Request, body []byte) error {
|
||||
secret := strings.TrimSpace(a.authCfg.InternalSecret)
|
||||
if secret == "" {
|
||||
return ErrForbidden
|
||||
}
|
||||
serviceName := strings.TrimSpace(r.Header.Get("X-Service-Name"))
|
||||
if expected := strings.TrimSpace(a.authCfg.InternalServiceName); expected != "" && serviceName != expected {
|
||||
return ErrForbidden
|
||||
}
|
||||
timestampRaw := strings.TrimSpace(r.Header.Get("X-Timestamp"))
|
||||
nonce := strings.TrimSpace(r.Header.Get("X-Nonce"))
|
||||
signature := strings.TrimSpace(r.Header.Get("X-Signature"))
|
||||
if timestampRaw == "" || nonce == "" || signature == "" {
|
||||
return ErrForbidden
|
||||
}
|
||||
ts, err := strconv.ParseInt(timestampRaw, 10, 64)
|
||||
if err != nil {
|
||||
return ErrForbidden
|
||||
}
|
||||
when := time.Unix(ts, 0)
|
||||
if time.Since(when) > internalSignatureWindow || time.Until(when) > internalSignatureWindow {
|
||||
return ErrForbidden
|
||||
}
|
||||
bodyHash := sha256.Sum256(body)
|
||||
signingText := strings.Join([]string{
|
||||
r.Method,
|
||||
r.URL.RequestURI(),
|
||||
hex.EncodeToString(bodyHash[:]),
|
||||
timestampRaw,
|
||||
nonce,
|
||||
}, "\n")
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(signingText))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(expected), []byte(signature)) {
|
||||
return ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
func TestValidateMailboxSubmissionRejectsTooLongContent(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/workbench/executive-mailbox/submissions", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
if validateMailboxSubmission(rec, req, "建议", strings.Repeat("a", maxMailboxContentLength+1)) {
|
||||
t.Fatal("validateMailboxSubmission returned true for too-long content")
|
||||
}
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body apiError
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if body.Reason != reasonValidationFailed {
|
||||
t.Fatalf("reason=%q body=%s", body.Reason, rec.Body.String())
|
||||
}
|
||||
if body.Details["field"] != "content" {
|
||||
t.Fatalf("details=%v", body.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStoreErrorMapsPolicyToValidationFailed(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/workbench/executive-mailbox/submissions", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
writeStoreError(rec, req, store.ErrPolicy)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body apiError
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if body.Reason != reasonValidationFailed {
|
||||
t.Fatalf("reason=%q body=%s", body.Reason, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/mall"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
func (a *API) manualProducts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
if a.mall == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, ok := optionalPositiveInt(w, r, "page")
|
||||
if !ok {
|
||||
writeMallError(w, r, mall.ErrPageInvalid)
|
||||
return
|
||||
}
|
||||
pageSize, ok := optionalPositiveInt(w, r, "page_size")
|
||||
if !ok {
|
||||
writeMallError(w, r, mall.ErrPageSizeInvalid)
|
||||
return
|
||||
}
|
||||
categoryID, ok := optionalNonNegativeInt64(r.URL.Query().Get("category_id"))
|
||||
if !ok {
|
||||
writeMallError(w, r, mall.ErrCategoryIDInvalid)
|
||||
return
|
||||
}
|
||||
result, err := a.mall.Products(r.Context(), principal.TenantID, categoryID, page, pageSize)
|
||||
if err != nil {
|
||||
writeMallError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) manualProduct(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
if a.mall == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return
|
||||
}
|
||||
productIDText := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/manual-products/"), "/")
|
||||
if productIDText == "" || strings.Contains(productIDText, "/") {
|
||||
writeMallError(w, r, mall.ErrProductIDInvalid)
|
||||
return
|
||||
}
|
||||
productID, err := strconv.ParseInt(productIDText, 10, 64)
|
||||
if err != nil || productID <= 0 {
|
||||
writeMallError(w, r, mall.ErrProductIDInvalid)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := a.mall.Product(r.Context(), principal.TenantID, productID)
|
||||
if err != nil {
|
||||
writeMallError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func optionalNonNegativeInt64(value string) (int64, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, true
|
||||
}
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
return parsed, err == nil && parsed >= 0
|
||||
}
|
||||
|
||||
func writeMallError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, mall.ErrCategoryIDInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "CATEGORY_ID_INVALID", "validation failed", map[string]any{"field": "category_id"})
|
||||
case errors.Is(err, mall.ErrProductIDInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PRODUCT_ID_INVALID", "validation failed", map[string]any{"field": "product_id"})
|
||||
case errors.Is(err, mall.ErrPageInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"})
|
||||
case errors.Is(err, mall.ErrPageSizeInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"})
|
||||
case errors.Is(err, store.ErrResourceNotFound):
|
||||
writeError(w, r, http.StatusNotFound, 30044, "PRODUCT_NOT_FOUND", "manual product not found", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
}
|
||||
@@ -1,547 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
func workbenchModulePayloads(modules []store.WorkbenchModule) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(modules))
|
||||
for _, module := range modules {
|
||||
items = append(items, map[string]any{
|
||||
"code": module.Code,
|
||||
"name": module.Name,
|
||||
"icon_file_id": module.IconFileID,
|
||||
"route": module.Route,
|
||||
"sort_order": module.SortOrder,
|
||||
"unread_count": module.UnreadCount,
|
||||
"enabled": module.Enabled,
|
||||
"permission": firstNonEmpty(module.Permission, "enabled"),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func organizationDepartmentPayloads(departments []store.OrganizationDepartment) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(departments))
|
||||
for _, department := range departments {
|
||||
items = append(items, organizationDepartmentPayload(department))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func organizationDepartmentPayload(department store.OrganizationDepartment) map[string]any {
|
||||
return map[string]any{
|
||||
"department_id": department.DepartmentID,
|
||||
"name": department.Name,
|
||||
"parent_id": department.ParentID,
|
||||
"child_count": department.ChildCount,
|
||||
"member_count": department.MemberCount,
|
||||
"version": department.Version,
|
||||
}
|
||||
}
|
||||
|
||||
func organizationMemberPayloads(members []store.OrganizationMember) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(members))
|
||||
for _, member := range members {
|
||||
items = append(items, organizationMemberPayload(member))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func organizationMemberPayload(member store.OrganizationMember) map[string]any {
|
||||
payload := map[string]any{
|
||||
"user_id": formatUserID(member.UserID),
|
||||
"department_id": member.DepartmentID,
|
||||
"display_name": member.DisplayName,
|
||||
"position": member.Position,
|
||||
"avatar_file_id": member.AvatarFileID,
|
||||
"status": member.Status,
|
||||
"contactable": member.Contactable,
|
||||
"version": member.Version,
|
||||
}
|
||||
if member.Mobile != "" {
|
||||
payload["mobile"] = member.Mobile
|
||||
}
|
||||
if member.Email != "" {
|
||||
payload["email"] = member.Email
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func attendanceConfigPayload(config *store.AttendanceConfig) map[string]any {
|
||||
return map[string]any{
|
||||
"date": config.Date.Format("2006-01-02"),
|
||||
"timezone": config.Timezone,
|
||||
"rule": attendanceRulePayload(config.Rule),
|
||||
"shifts": attendanceShiftPayloads(config.Shifts),
|
||||
"allowed_actions": []string{"check_in", "check_out"},
|
||||
"today_record": attendanceRecordPayload(config.TodayRecord),
|
||||
"permissions": map[string]any{
|
||||
"can_checkin": true,
|
||||
"can_view_managed": true,
|
||||
"can_appeal": config.Rule.AppealAllowed,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceRulePayload(rule store.AttendanceRule) map[string]any {
|
||||
data := map[string]any{
|
||||
"rule_id": rule.RuleID,
|
||||
"name": rule.Name,
|
||||
"workday": rule.Workday,
|
||||
"location_required": rule.LocationRequired,
|
||||
"wifi_required": rule.WifiRequired,
|
||||
"allow_outside_checkin": rule.AllowOutsideCheckin,
|
||||
"appeal_allowed": rule.AppealAllowed,
|
||||
}
|
||||
if rule.AppealDeadline != nil {
|
||||
data["appeal_deadline"] = rule.AppealDeadline.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func attendanceShiftPayloads(shifts []store.AttendanceShift) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(shifts))
|
||||
for _, shift := range shifts {
|
||||
items = append(items, map[string]any{
|
||||
"shift_id": shift.ShiftID,
|
||||
"name": shift.Name,
|
||||
"work_start": shift.WorkStart,
|
||||
"work_end": shift.WorkEnd,
|
||||
"checkin_start": shift.CheckinStart,
|
||||
"checkout_end": shift.CheckoutEnd,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func attendanceRecordPayloads(records []store.AttendanceRecord) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(records))
|
||||
for i := range records {
|
||||
items = append(items, attendanceRecordPayload(&records[i]))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func attendanceRecordPayload(record *store.AttendanceRecord) map[string]any {
|
||||
if record == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"record_id": record.RecordID,
|
||||
"user_id": formatUserID(record.UserID),
|
||||
"date": record.Date.Format("2006-01-02"),
|
||||
"rule_id": record.RuleID,
|
||||
"shift_id": record.ShiftID,
|
||||
"status": record.Status,
|
||||
"checkins": attendanceCheckinPayloads(record.Checkins),
|
||||
"exceptions": jsonList(record.Exceptions),
|
||||
"appeal_status": record.AppealStatus,
|
||||
"version": record.Version,
|
||||
"created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceCheckinPayloads(checkins []store.AttendanceCheckin) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(checkins))
|
||||
for _, checkin := range checkins {
|
||||
items = append(items, attendanceCheckinPayload(checkin))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func attendanceCheckinPayload(checkin store.AttendanceCheckin) map[string]any {
|
||||
return map[string]any{
|
||||
"checkin_id": checkin.CheckinID,
|
||||
"action": checkin.Action,
|
||||
"client_time": checkin.ClientTime.UTC().Format(time.RFC3339Nano),
|
||||
"server_time": checkin.ServerTime.UTC().Format(time.RFC3339Nano),
|
||||
"result": checkin.Result,
|
||||
"location": jsonObject(checkin.Location),
|
||||
"wifi": jsonObject(checkin.Wifi),
|
||||
"device": jsonObject(checkin.Device),
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceStatsPayload(stats *store.AttendanceStats) map[string]any {
|
||||
if stats == nil {
|
||||
return map[string]any{"expected_count": 0, "checked_count": 0, "exception_count": 0, "not_checked_count": 0}
|
||||
}
|
||||
return map[string]any{
|
||||
"expected_count": stats.ExpectedCount,
|
||||
"checked_count": stats.CheckedCount,
|
||||
"exception_count": stats.ExceptionCount,
|
||||
"not_checked_count": stats.NotCheckedCount,
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceAppealPayloads(appeals []store.AttendanceAppeal) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(appeals))
|
||||
for i := range appeals {
|
||||
items = append(items, attendanceAppealPayload(&appeals[i]))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func attendanceAppealPayload(appeal *store.AttendanceAppeal) map[string]any {
|
||||
if appeal == nil {
|
||||
return nil
|
||||
}
|
||||
data := map[string]any{
|
||||
"appeal_id": appeal.AppealID,
|
||||
"record_id": appeal.RecordID,
|
||||
"user_id": formatUserID(appeal.UserID),
|
||||
"appeal_type": appeal.AppealType,
|
||||
"reason": appeal.Reason,
|
||||
"target_action": appeal.TargetAction,
|
||||
"attachment_file_ids": jsonList(appeal.AttachmentFileIDs),
|
||||
"status": appeal.Status,
|
||||
"version": appeal.Version,
|
||||
"submitted_at": appeal.SubmittedAt.UTC().Format(time.RFC3339Nano),
|
||||
"created_at": appeal.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"updated_at": appeal.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if appeal.TargetTime != nil {
|
||||
data["target_time"] = appeal.TargetTime.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func meetingPayloads(meetings []store.Meeting, viewer int64) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(meetings))
|
||||
for _, meeting := range meetings {
|
||||
items = append(items, meetingPayload(meeting, viewer))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func meetingPayload(meeting store.Meeting, viewer int64) map[string]any {
|
||||
participants := meetingParticipantPayloads(meeting.Participants)
|
||||
recorderID := meeting.RecorderID
|
||||
if recorderID == 0 {
|
||||
recorderID = meeting.OrganizerID
|
||||
}
|
||||
organizer := userSummary(meeting.OrganizerID, meeting.Organizer)
|
||||
recorder := userSummary(recorderID, meeting.Recorder)
|
||||
state := meetingState(meeting)
|
||||
data := map[string]any{
|
||||
"meeting_id": meeting.MeetingID,
|
||||
"title": meeting.Title,
|
||||
"organizer_id": formatUserID(meeting.OrganizerID),
|
||||
"organizer": organizer,
|
||||
"organizer_display_name": organizer["display_name"],
|
||||
"recorder_user_id": formatUserID(recorderID),
|
||||
"recorder": recorder,
|
||||
"recorder_display_name": recorder["display_name"],
|
||||
"start_at": meeting.StartAt.UTC().Format(time.RFC3339Nano),
|
||||
"end_at": meeting.EndAt.UTC().Format(time.RFC3339Nano),
|
||||
"meeting_time": timeRange(meeting.StartAt, meeting.EndAt),
|
||||
"time_range": timeRange(meeting.StartAt, meeting.EndAt),
|
||||
"location": meeting.Location,
|
||||
"state": state,
|
||||
"reminder_minutes": jsonList(meeting.ReminderMinutes),
|
||||
"version": meeting.Version,
|
||||
"participants": participants,
|
||||
"participant_count": len(participants),
|
||||
"participant_names": meetingParticipantNames(participants),
|
||||
"permissions": meetingPermissions(meeting, viewer),
|
||||
"created_at": meeting.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"updated_at": meeting.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if meeting.CanceledAt != nil {
|
||||
data["canceled_at"] = meeting.CanceledAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
for _, participant := range meeting.Participants {
|
||||
if participant.UserID == viewer {
|
||||
data["my_response"] = participant.Response
|
||||
break
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func meetingParticipantPayloads(participants []store.MeetingParticipant) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(participants))
|
||||
for _, participant := range participants {
|
||||
summary := userSummary(participant.UserID, participant.User)
|
||||
item := map[string]any{"user_id": formatUserID(participant.UserID), "response": participant.Response, "display_name": summary["display_name"]}
|
||||
if avatarFileID, ok := summary["avatar_file_id"]; ok {
|
||||
item["avatar_file_id"] = avatarFileID
|
||||
}
|
||||
if participant.ResponseAt != nil {
|
||||
item["response_at"] = participant.ResponseAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func meetingNoteListPayloads(meetings []store.Meeting, viewer int64) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(meetings))
|
||||
for _, meeting := range meetings {
|
||||
item := meetingPayload(meeting, viewer)
|
||||
item["note_status"] = "empty"
|
||||
item["file_count"] = 0
|
||||
item["permissions"] = meetingNotePermissions(meeting, viewer)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func meetingNotesDetailPayload(meeting store.Meeting, files []store.MeetingNoteFile, viewer int64) map[string]any {
|
||||
meetingData := meetingPayload(meeting, viewer)
|
||||
fileItems := make([]map[string]any, 0, len(files))
|
||||
for _, file := range files {
|
||||
fileItems = append(fileItems, meetingNoteFilePayload(file, viewer))
|
||||
}
|
||||
data := map[string]any{}
|
||||
for k, v := range meetingData {
|
||||
data[k] = v
|
||||
}
|
||||
data["meeting"] = meetingData
|
||||
data["note_status"] = "empty"
|
||||
if len(files) > 0 {
|
||||
data["note_status"] = "available"
|
||||
}
|
||||
data["file_count"] = len(files)
|
||||
data["files"] = fileItems
|
||||
data["permissions"] = meetingNotePermissions(meeting, viewer)
|
||||
return data
|
||||
}
|
||||
|
||||
func meetingNoteFilePayload(file store.MeetingNoteFile, viewer int64) map[string]any {
|
||||
extension := meetingNoteFileExtension(file.FileName, file.MIMEType)
|
||||
return map[string]any{
|
||||
"note_file_id": file.NoteFileID,
|
||||
"meeting_id": file.MeetingID,
|
||||
"file_id": file.FileID,
|
||||
"name": file.FileName,
|
||||
"file_name": file.FileName,
|
||||
"mime": file.MIMEType,
|
||||
"mime_type": file.MIMEType,
|
||||
"size": file.FileSize,
|
||||
"file_size": file.FileSize,
|
||||
"file_extension": extension,
|
||||
"file_etag": file.FileETag,
|
||||
"status": file.Status,
|
||||
"preview_status": file.PreviewStatus,
|
||||
"preview_mode": file.PreviewMode,
|
||||
"preview_file_id": file.PreviewFileID,
|
||||
"uploader_user_id": formatUserID(file.UploaderID),
|
||||
"uploader": userSummary(file.UploaderID, file.Uploader),
|
||||
"version": file.Version,
|
||||
"permissions": map[string]any{
|
||||
"can_preview": file.Status == "active",
|
||||
"can_download": file.Status == "active",
|
||||
"can_replace": file.Status == "active" && file.UploaderID == viewer,
|
||||
"can_delete": file.Status == "active" && file.UploaderID == viewer,
|
||||
},
|
||||
"created_at": file.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"updated_at": file.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
func meetingNoteFileExtension(fileName, mimeType string) string {
|
||||
if ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(strings.TrimSpace(fileName)), ".")); ext != "" {
|
||||
return ext
|
||||
}
|
||||
exts, _ := mime.ExtensionsByType(strings.TrimSpace(mimeType))
|
||||
if len(exts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimPrefix(exts[0], "."))
|
||||
}
|
||||
|
||||
func dailyReportPayloads(reports []store.DailyReport) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(reports))
|
||||
for _, report := range reports {
|
||||
items = append(items, dailyReportPayload(report))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func dailyReportPayload(report store.DailyReport) map[string]any {
|
||||
data := map[string]any{
|
||||
"report_id": report.ReportID,
|
||||
"author_id": formatUserID(report.AuthorID),
|
||||
"report_date": report.ReportDate.Format("2006-01-02"),
|
||||
"content": report.Content,
|
||||
"content_short": preview(report.Content),
|
||||
"attachments": jsonList(report.Attachments),
|
||||
"status": report.Status,
|
||||
"read_state": report.ReadState,
|
||||
"version": report.Version,
|
||||
"draft_updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"created_at": report.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if report.SubmittedAt != nil {
|
||||
data["submitted_at"] = report.SubmittedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func monthlyReportPayloads(reports []store.MonthlyReport) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(reports))
|
||||
for _, report := range reports {
|
||||
items = append(items, monthlyReportPayload(report))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func monthlyReportPayload(report store.MonthlyReport) map[string]any {
|
||||
data := map[string]any{
|
||||
"report_id": report.ReportID,
|
||||
"author_id": formatUserID(report.AuthorID),
|
||||
"month": report.Month,
|
||||
"work_summary": report.WorkSummary,
|
||||
"next_plan": report.NextPlan,
|
||||
"attachments": jsonList(report.Attachments),
|
||||
"status": report.Status,
|
||||
"read_state": report.ReadState,
|
||||
"version": report.Version,
|
||||
"draft_updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"created_at": report.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"updated_at": report.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if report.SubmittedAt != nil {
|
||||
data["submitted_at"] = report.SubmittedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func reportStatsPayload(stats *store.ReportStats) map[string]any {
|
||||
if stats == nil {
|
||||
return map[string]any{
|
||||
"expected_count": 0,
|
||||
"submitted_count": 0,
|
||||
"missing_count": 0,
|
||||
"scope": map[string]any{"department_id": ""},
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"expected_count": stats.ExpectedCount,
|
||||
"submitted_count": stats.SubmittedCount,
|
||||
"missing_count": stats.MissingCount,
|
||||
"scope": map[string]any{"department_id": stats.DepartmentID},
|
||||
}
|
||||
}
|
||||
|
||||
func mailboxConfigPayload(config store.MailboxConfig) map[string]any {
|
||||
return map[string]any{
|
||||
"enabled": config.Enabled,
|
||||
"allow_anonymous": config.AllowAnonymous,
|
||||
"required_fields": jsonList(config.RequiredFields),
|
||||
"max_length": config.MaxLength,
|
||||
"attachment_policy": jsonObject(config.AttachmentPolicy),
|
||||
"notice": map[string]any{
|
||||
"title": config.NoticeTitle,
|
||||
"content": config.NoticeContent,
|
||||
"version": config.Version,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mailboxSubmissionPayload(submission store.MailboxSubmission) map[string]any {
|
||||
return map[string]any{
|
||||
"submission_id": submission.SubmissionID,
|
||||
"sender_id": formatUserID(submission.SenderID),
|
||||
"subject": submission.Subject,
|
||||
"anonymous": submission.Anonymous,
|
||||
"attachment_file_ids": jsonList(submission.AttachmentFileIDs),
|
||||
"notice_version": submission.NoticeVersion,
|
||||
"status": submission.Status,
|
||||
"submitted_at": submission.SubmittedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
func meetingPermissions(meeting store.Meeting, viewer int64) map[string]any {
|
||||
isOrganizer := meeting.OrganizerID == viewer
|
||||
isScheduled := meetingState(meeting) == "scheduled"
|
||||
return map[string]any{
|
||||
"can_update": isOrganizer && isScheduled,
|
||||
"can_cancel": isOrganizer && isScheduled,
|
||||
"can_remind": isOrganizer && isScheduled,
|
||||
"can_respond": !isOrganizer && isScheduled,
|
||||
}
|
||||
}
|
||||
|
||||
func meetingNotePermissions(meeting store.Meeting, viewer int64) map[string]any {
|
||||
recorderID := meeting.RecorderID
|
||||
if recorderID == 0 {
|
||||
recorderID = meeting.OrganizerID
|
||||
}
|
||||
readable := meetingState(meeting) == "ended"
|
||||
canWrite := readable && (recorderID == viewer || meeting.OrganizerID == viewer)
|
||||
return map[string]any{"can_view": true, "can_upload": canWrite, "can_replace": canWrite, "can_delete": canWrite, "can_download": true}
|
||||
}
|
||||
|
||||
func userSummary(userID int64, summary store.UserSummary) map[string]any {
|
||||
displayName := strings.TrimSpace(summary.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = formatUserID(userID)
|
||||
}
|
||||
data := map[string]any{"user_id": formatUserID(userID), "display_name": displayName}
|
||||
if strings.TrimSpace(summary.AvatarFileID) != "" {
|
||||
data["avatar_file_id"] = strings.TrimSpace(summary.AvatarFileID)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func meetingState(meeting store.Meeting) string {
|
||||
state := strings.TrimSpace(meeting.State)
|
||||
if state == "scheduled" && !meeting.EndAt.IsZero() && !time.Now().UTC().Before(meeting.EndAt.UTC()) {
|
||||
return "ended"
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func meetingParticipantNames(participants []map[string]any) []string {
|
||||
names := make([]string, 0, len(participants))
|
||||
for _, participant := range participants {
|
||||
name, _ := participant["display_name"].(string)
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func jsonList(raw string) []any {
|
||||
var items []any
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &items); err != nil {
|
||||
return []any{}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func jsonObject(raw string) map[string]any {
|
||||
var item map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &item); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func preview(value string) string {
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
if len(runes) <= 40 {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[:40])
|
||||
}
|
||||
|
||||
func timeRange(startAt, endAt time.Time) string {
|
||||
if startAt.Format("2006-01-02") == endAt.Format("2006-01-02") {
|
||||
return startAt.Format("2006-01-02 15:04") + "-" + endAt.Format("15:04")
|
||||
}
|
||||
return startAt.Format("2006-01-02 15:04") + "-" + endAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
func TestPayloadUserIDsAreStrings(t *testing.T) {
|
||||
now := time.Date(2026, 9, 16, 9, 30, 0, 0, time.UTC)
|
||||
userA, err := parseUserID("usrag7w9xJ6hto")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userB, err := parseUserID("usr1RqTFgHSenM")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
payloads := []map[string]any{
|
||||
organizationMemberPayload(store.OrganizationMember{UserID: userA}),
|
||||
attendanceRecordPayload(&store.AttendanceRecord{RecordID: "att_1", UserID: userA, Date: now, CreatedAt: now, UpdatedAt: now}),
|
||||
attendanceAppealPayload(&store.AttendanceAppeal{AppealID: "atta_1", UserID: userA, SubmittedAt: now, CreatedAt: now, UpdatedAt: now}),
|
||||
meetingPayload(store.Meeting{
|
||||
MeetingID: "mtg_1",
|
||||
OrganizerID: userA,
|
||||
RecorderID: userB,
|
||||
StartAt: now,
|
||||
EndAt: now.Add(time.Hour),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Participants: []store.MeetingParticipant{
|
||||
{UserID: userA, Response: "accepted"},
|
||||
{UserID: userB, Response: "pending"},
|
||||
},
|
||||
}, userA),
|
||||
meetingNoteFilePayload(store.MeetingNoteFile{NoteFileID: "mnote_1", UploaderID: userA, CreatedAt: now, UpdatedAt: now}, userA),
|
||||
dailyReportPayload(store.DailyReport{ReportID: "dr_1", AuthorID: userA, ReportDate: now, CreatedAt: now, UpdatedAt: now}),
|
||||
monthlyReportPayload(store.MonthlyReport{ReportID: "mr_1", AuthorID: userA, CreatedAt: now, UpdatedAt: now}),
|
||||
mailboxSubmissionPayload(store.MailboxSubmission{SubmissionID: "mb_1", SenderID: userA, SubmittedAt: now}),
|
||||
}
|
||||
|
||||
for _, payload := range payloads {
|
||||
assertNoNumericUserIDs(t, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingPayloadIncludesNamesAndDerivedEndedState(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
userA, err := parseUserID("usrag7w9xJ6hto")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userB, err := parseUserID("usr1RqTFgHSenM")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
payload := meetingPayload(store.Meeting{
|
||||
MeetingID: "mtg_1",
|
||||
Title: "周会",
|
||||
OrganizerID: userA,
|
||||
RecorderID: userA,
|
||||
Organizer: store.UserSummary{UserID: userA, DisplayName: "张三"},
|
||||
Recorder: store.UserSummary{UserID: userA, DisplayName: "张三"},
|
||||
StartAt: now.Add(-2 * time.Hour),
|
||||
EndAt: now.Add(-time.Hour),
|
||||
State: "scheduled",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Participants: []store.MeetingParticipant{
|
||||
{UserID: userB, Response: "pending", User: store.UserSummary{UserID: userB, DisplayName: "李四"}},
|
||||
},
|
||||
}, userA)
|
||||
|
||||
if payload["state"] != "ended" {
|
||||
t.Fatalf("state=%v, want ended", payload["state"])
|
||||
}
|
||||
if payload["organizer_display_name"] != "张三" {
|
||||
t.Fatalf("organizer_display_name=%v", payload["organizer_display_name"])
|
||||
}
|
||||
names, ok := payload["participant_names"].([]string)
|
||||
if !ok || len(names) != 1 || names[0] != "李四" {
|
||||
t.Fatalf("participant_names=%#v", payload["participant_names"])
|
||||
}
|
||||
participants, ok := payload["participants"].([]map[string]any)
|
||||
if !ok || len(participants) != 1 || participants[0]["display_name"] != "李四" {
|
||||
t.Fatalf("participants=%#v", payload["participants"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingNoteFilePayloadKeepsOriginalNameAndExtension(t *testing.T) {
|
||||
now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)
|
||||
payload := meetingNoteFilePayload(store.MeetingNoteFile{
|
||||
NoteFileID: "mnote_1",
|
||||
FileID: "file_1",
|
||||
FileName: "会议纪要.pdf",
|
||||
MIMEType: "application/pdf",
|
||||
FileSize: 2048,
|
||||
UploaderID: 1,
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, 1)
|
||||
if payload["file_name"] != "会议纪要.pdf" || payload["name"] != "会议纪要.pdf" {
|
||||
t.Fatalf("payload names = file_name:%v name:%v", payload["file_name"], payload["name"])
|
||||
}
|
||||
if payload["file_extension"] != "pdf" {
|
||||
t.Fatalf("file_extension = %v, want pdf", payload["file_extension"])
|
||||
}
|
||||
if payload["mime"] != "application/pdf" || payload["size"] != int64(2048) {
|
||||
t.Fatalf("payload aliases = mime:%v size:%v", payload["mime"], payload["size"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetingNoteFilePayloadUsesUploaderSummary(t *testing.T) {
|
||||
now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)
|
||||
payload := meetingNoteFilePayload(store.MeetingNoteFile{
|
||||
NoteFileID: "mnote_1",
|
||||
UploaderID: 1573637795346386944,
|
||||
Uploader: store.UserSummary{
|
||||
UserID: 1573637795346386944,
|
||||
DisplayName: "jcharles3",
|
||||
},
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, 1573637795346386944)
|
||||
uploader, ok := payload["uploader"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("uploader=%#v", payload["uploader"])
|
||||
}
|
||||
if uploader["display_name"] != "jcharles3" {
|
||||
t.Fatalf("uploader.display_name=%v, want jcharles3", uploader["display_name"])
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoNumericUserIDs(t *testing.T, value any) {
|
||||
t.Helper()
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for key, item := range typed {
|
||||
if isUserIDKey(key) {
|
||||
if _, ok := item.(string); !ok {
|
||||
t.Fatalf("%s is %T, want string", key, item)
|
||||
}
|
||||
}
|
||||
assertNoNumericUserIDs(t, item)
|
||||
}
|
||||
case []map[string]any:
|
||||
for _, item := range typed {
|
||||
assertNoNumericUserIDs(t, item)
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
assertNoNumericUserIDs(t, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isUserIDKey(key string) bool {
|
||||
return key == "user_id" ||
|
||||
key == "organizer_id" ||
|
||||
key == "recorder_user_id" ||
|
||||
key == "uploader_user_id" ||
|
||||
key == "author_id" ||
|
||||
key == "sender_id" ||
|
||||
strings.HasSuffix(key, "_user_id")
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/portal"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
var publicContentKeys = map[string]struct{}{
|
||||
"privacy_policy": {},
|
||||
"user_agreement": {},
|
||||
}
|
||||
|
||||
func (a *API) home(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
if !a.ensurePortal(w, r) {
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
profile, err := a.accounts.Profile(r.Context(), principal.TenantID, principal.MemberID)
|
||||
if err != nil {
|
||||
writeProfileError(w, r, err)
|
||||
return
|
||||
}
|
||||
result, err := a.portal.Home(r.Context(), principal.TenantID, principal.MemberID, profile)
|
||||
if err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) banners(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
if !a.ensurePortal(w, r) {
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
position := strings.TrimSpace(r.URL.Query().Get("position"))
|
||||
if position == "" {
|
||||
position = "home_top"
|
||||
}
|
||||
items, err := a.portal.Banners(r.Context(), principal.TenantID, position)
|
||||
if err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, items)
|
||||
}
|
||||
|
||||
func (a *API) content(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
if !a.ensurePortal(w, r) {
|
||||
return
|
||||
}
|
||||
if a.auth == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return
|
||||
}
|
||||
key := strings.TrimPrefix(r.URL.Path, "/v1/contents/")
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
if strings.Contains(key, "/") {
|
||||
writePortalError(w, r, portal.ErrContentKeyInvalid)
|
||||
return
|
||||
}
|
||||
tenantID := a.auth.DefaultTenantID()
|
||||
if _, public := publicContentKeys[key]; !public {
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tenantID = principal.TenantID
|
||||
}
|
||||
result, err := a.portal.Content(r.Context(), tenantID, key)
|
||||
if err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) memberLevels(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
if !a.ensurePortal(w, r) {
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := a.portal.MemberLevels(r.Context(), principal.TenantID)
|
||||
if err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, items)
|
||||
}
|
||||
|
||||
func (a *API) ensurePortal(w http.ResponseWriter, r *http.Request) bool {
|
||||
if a.portal != nil {
|
||||
return true
|
||||
}
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return false
|
||||
}
|
||||
|
||||
func writePortalError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, portal.ErrPositionInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "BANNER_POSITION_INVALID", "validation failed", map[string]any{"field": "position"})
|
||||
case errors.Is(err, portal.ErrContentKeyInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "CONTENT_KEY_INVALID", "validation failed", map[string]any{"field": "content_key"})
|
||||
case errors.Is(err, store.ErrResourceNotFound):
|
||||
writeError(w, r, http.StatusNotFound, 30044, "RESOURCE_NOT_FOUND", "resource not found", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
accountsvc "git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
type avatarRequest struct {
|
||||
AttachmentID int64 `json:"attachment_id"`
|
||||
}
|
||||
|
||||
type passwordRequest struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
type tradePasswordRequest struct {
|
||||
LoginPassword string `json:"login_password"`
|
||||
OldTradePassword string `json:"old_trade_password,omitempty"`
|
||||
NewTradePassword string `json:"new_trade_password"`
|
||||
}
|
||||
|
||||
func (a *API) memberProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
profile, err := a.accounts.Profile(r.Context(), principal.TenantID, principal.MemberID)
|
||||
if err != nil {
|
||||
writeProfileError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, profile)
|
||||
}
|
||||
|
||||
func (a *API) memberAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPatch {
|
||||
methodNotAllowed(w, r, http.MethodPatch)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body avatarRequest
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
avatar, err := a.accounts.ChangeAvatar(r.Context(), principal.TenantID, principal.MemberID, body.AttachmentID)
|
||||
if err != nil {
|
||||
writeProfileError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, avatar)
|
||||
}
|
||||
|
||||
func (a *API) memberPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
methodNotAllowed(w, r, http.MethodPut)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body passwordRequest
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
err := a.accounts.ChangePassword(r.Context(), principal.TenantID, principal.MemberID, accountsvc.ChangePasswordInput{
|
||||
OldPassword: body.OldPassword, NewPassword: body.NewPassword,
|
||||
})
|
||||
if err != nil {
|
||||
writeProfileError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := a.auth.RevokeMemberSessions(r.Context(), principal.TenantID, principal.MemberID); err != nil {
|
||||
writeProfileError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, nil)
|
||||
}
|
||||
|
||||
func (a *API) memberTradePassword(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
methodNotAllowed(w, r, http.MethodPut)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body tradePasswordRequest
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
err := a.accounts.ChangeTradePassword(r.Context(), principal.TenantID, principal.MemberID, accountsvc.ChangeTradePasswordInput{
|
||||
LoginPassword: body.LoginPassword, OldTradePassword: body.OldTradePassword,
|
||||
NewTradePassword: body.NewTradePassword,
|
||||
})
|
||||
if err != nil {
|
||||
writeProfileError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, nil)
|
||||
}
|
||||
|
||||
func (a *API) authenticateMember(w http.ResponseWriter, r *http.Request) (*auth.Principal, bool) {
|
||||
if a.auth == nil || a.accounts == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return nil, false
|
||||
}
|
||||
principal, err := a.auth.Authenticate(r)
|
||||
if err == nil {
|
||||
return principal, true
|
||||
}
|
||||
if errors.Is(err, auth.ErrForbidden) {
|
||||
writeError(w, r, http.StatusForbidden, 30003, reasonForbidden, "forbidden", nil)
|
||||
} else if errors.Is(err, auth.ErrAuthRequired) {
|
||||
writeError(w, r, http.StatusUnauthorized, 30041, "AUTH_REQUIRED", "authentication required", nil)
|
||||
} else {
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func writeProfileError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, accountsvc.ErrAttachmentInvalid), errors.Is(err, store.ErrAttachmentInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "AVATAR_ATTACHMENT_INVALID", "validation failed", map[string]any{"field": "attachment_id"})
|
||||
case errors.Is(err, accountsvc.ErrCurrentPassword):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "CURRENT_PASSWORD_INVALID", "current password is invalid", map[string]any{"field": "old_password"})
|
||||
case errors.Is(err, accountsvc.ErrLoginPassword):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "LOGIN_PASSWORD_INVALID", "login password is invalid", map[string]any{"field": "login_password"})
|
||||
case errors.Is(err, accountsvc.ErrPasswordInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "NEW_PASSWORD_INVALID", "validation failed", map[string]any{"field": "new_password"})
|
||||
case errors.Is(err, accountsvc.ErrOldTradePasswordUnset):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "OLD_TRADE_PASSWORD_REQUIRED", "old trade password is required", map[string]any{"field": "old_trade_password"})
|
||||
case errors.Is(err, accountsvc.ErrOldTradePassword):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "OLD_TRADE_PASSWORD_INVALID", "old trade password is invalid", map[string]any{"field": "old_trade_password"})
|
||||
case errors.Is(err, accountsvc.ErrTradePassword):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "TRADE_PASSWORD_INVALID", "trade password is invalid", map[string]any{"field": "new_trade_password"})
|
||||
case errors.Is(err, store.ErrAccountNotFound):
|
||||
writeError(w, r, http.StatusNotFound, 30044, "ACCOUNT_NOT_FOUND", "account not found", nil)
|
||||
case errors.Is(err, store.ErrConcurrentUpdate):
|
||||
writeError(w, r, http.StatusConflict, 30040, "ACCOUNT_CHANGED", "account changed, retry the operation", nil)
|
||||
case errors.Is(err, accountsvc.ErrUnavailable):
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,15 @@ package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
reasonMalformedRequest = "MALFORMED_REQUEST"
|
||||
reasonMethodNotAllowed = "METHOD_NOT_ALLOWED"
|
||||
reasonAuthRequired = "AUTH_REQUIRED"
|
||||
reasonForbidden = "FORBIDDEN"
|
||||
reasonNotFound = "RESOURCE_NOT_FOUND"
|
||||
reasonConflict = "CONFLICT"
|
||||
reasonValidationFailed = "VALIDATION_FAILED"
|
||||
reasonInternal = "INTERNAL"
|
||||
)
|
||||
|
||||
@@ -32,44 +25,20 @@ type apiError struct {
|
||||
|
||||
func writeSuccess(w http.ResponseWriter, r *http.Request, data any) {
|
||||
writeJSON(w, r, http.StatusOK, map[string]any{
|
||||
"code": 0,
|
||||
"data": data,
|
||||
"request_id": requestID(r),
|
||||
"code": 0, "data": data, "request_id": requestID(r),
|
||||
"server_time": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, r *http.Request, status, code int, reason, message string, details map[string]any) {
|
||||
writeJSON(w, r, status, apiError{
|
||||
Code: code,
|
||||
Reason: reason,
|
||||
Message: message,
|
||||
Details: details,
|
||||
RequestID: requestID(r),
|
||||
ServerTime: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func writeStoreError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound), errors.Is(err, store.ErrNotFound):
|
||||
writeError(w, r, http.StatusNotFound, 30004, reasonNotFound, "resource not found", nil)
|
||||
case errors.Is(err, ErrForbidden), errors.Is(err, store.ErrForbidden), errors.Is(err, auth.ErrForbidden):
|
||||
writeError(w, r, http.StatusForbidden, 30041, reasonForbidden, "forbidden", nil)
|
||||
case errors.Is(err, ErrConflict), errors.Is(err, store.ErrConflict):
|
||||
writeError(w, r, http.StatusConflict, 30040, reasonConflict, "conflict", nil)
|
||||
case errors.Is(err, store.ErrPolicy):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, reasonValidationFailed, "validation failed", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
writeJSON(w, r, status, apiError{Code: code, Reason: reason, Message: message, Details: details,
|
||||
RequestID: requestID(r), ServerTime: time.Now().UTC().Format(time.RFC3339Nano)})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, r *http.Request, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("X-IM-Schema", firstNonEmpty(r.Header.Get("X-IM-Schema"), "1"))
|
||||
if rid := requestID(r); rid != "" {
|
||||
w.Header().Set("X-Request-ID", rid)
|
||||
if requestID := requestID(r); requestID != "" {
|
||||
w.Header().Set("X-Request-ID", requestID)
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
@@ -79,20 +48,21 @@ func requestID(r *http.Request) string {
|
||||
return r.Header.Get("X-Request-ID")
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
func decodeBody(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
defer r.Body.Close()
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(dst); err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil)
|
||||
return false
|
||||
}
|
||||
return ""
|
||||
return true
|
||||
}
|
||||
|
||||
func firstNonNilInt64(values ...*int64) *int64 {
|
||||
for _, value := range values {
|
||||
if value != nil {
|
||||
return value
|
||||
}
|
||||
func methodNotAllowed(w http.ResponseWriter, r *http.Request, allowed ...string) {
|
||||
if len(allowed) == 0 {
|
||||
allowed = []string{http.MethodPost}
|
||||
}
|
||||
return nil
|
||||
w.Header().Set("Allow", strings.Join(allowed, ", "))
|
||||
writeError(w, r, http.StatusMethodNotAllowed, 30005, reasonMethodNotAllowed, "method not allowed", nil)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/im_workbench/internal/tasks"
|
||||
)
|
||||
|
||||
type taskClaimRequest struct {
|
||||
HallID int64 `json:"hall_id"`
|
||||
}
|
||||
|
||||
func (a *API) taskHalls(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateTaskMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Halls(r.Context(), principal.TenantID, principal.MemberID)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) taskEligibility(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateTaskMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
hallID, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("hall_id")), 10, 64)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, tasks.ErrHallIDInvalid)
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Eligibility(r.Context(), principal.TenantID, principal.MemberID, hallID)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) taskStats(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateTaskMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Stats(r.Context(), principal.TenantID, principal.MemberID)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) taskClaim(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodPost)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateTaskMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body taskClaimRequest
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Claim(r.Context(), principal.TenantID, principal.MemberID, body.HallID)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) taskOrders(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateTaskMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, ok := optionalPositiveInt(w, r, "page")
|
||||
if !ok {
|
||||
writeTaskError(w, r, tasks.ErrPageInvalid)
|
||||
return
|
||||
}
|
||||
pageSize, ok := optionalPositiveInt(w, r, "page_size")
|
||||
if !ok {
|
||||
writeTaskError(w, r, tasks.ErrPageSizeInvalid)
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Orders(r.Context(), principal.TenantID, principal.MemberID, page, pageSize, r.URL.Query().Get("status"))
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) taskOrderResource(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/task-orders/"), "/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 1 || len(parts) > 2 || parts[0] == "" {
|
||||
writeTaskError(w, r, tasks.ErrOrderIDInvalid)
|
||||
return
|
||||
}
|
||||
orderID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || orderID <= 0 {
|
||||
writeTaskError(w, r, tasks.ErrOrderIDInvalid)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateTaskMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Order(r.Context(), principal.TenantID, principal.MemberID, orderID)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
return
|
||||
}
|
||||
if parts[1] != "submissions" {
|
||||
writeTaskError(w, r, store.ErrOrderNotFound)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var body tasks.SubmitInput
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Submit(r.Context(), principal.TenantID, principal.MemberID, orderID, body)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) authenticateTaskMember(w http.ResponseWriter, r *http.Request) (*auth.Principal, bool) {
|
||||
if a.tasks == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return nil, false
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return principal, true
|
||||
}
|
||||
|
||||
func optionalPositiveInt(w http.ResponseWriter, r *http.Request, name string) (int, bool) {
|
||||
value := strings.TrimSpace(r.URL.Query().Get(name))
|
||||
if value == "" {
|
||||
return 0, true
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
return parsed, err == nil && parsed > 0
|
||||
}
|
||||
|
||||
func writeTaskError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, tasks.ErrHallIDInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "HALL_ID_INVALID", "validation failed", map[string]any{"field": "hall_id"})
|
||||
case errors.Is(err, tasks.ErrOrderIDInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "ORDER_ID_INVALID", "validation failed", map[string]any{"field": "order_id"})
|
||||
case errors.Is(err, tasks.ErrPageInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"})
|
||||
case errors.Is(err, tasks.ErrPageSizeInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"})
|
||||
case errors.Is(err, tasks.ErrStatusInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "ORDER_STATUS_INVALID", "validation failed", map[string]any{"field": "status"})
|
||||
case errors.Is(err, tasks.ErrEvidenceInvalid), errors.Is(err, store.ErrEvidenceInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "EVIDENCE_INVALID", "validation failed", map[string]any{"field": "evidence"})
|
||||
case errors.Is(err, tasks.ErrTaskDisabled), errors.Is(err, store.ErrTaskDisabled):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "TASK_DISABLED", "task feature is disabled", nil)
|
||||
case errors.Is(err, tasks.ErrOutsideBusinessHour), errors.Is(err, store.ErrOutsideBusinessHours):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "OUTSIDE_BUSINESS_HOURS", "outside business hours", nil)
|
||||
case errors.Is(err, store.ErrInsufficientBalance):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "INSUFFICIENT_BALANCE", "insufficient available balance", nil)
|
||||
case errors.Is(err, store.ErrDailyLimitReached):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "DAILY_LIMIT_REACHED", "daily claim limit reached", nil)
|
||||
case errors.Is(err, store.ErrLevelNotEligible), errors.Is(err, store.ErrTaskHallNotFound):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "LEVEL_NOT_ELIGIBLE", "member level is not eligible", nil)
|
||||
case errors.Is(err, store.ErrAccountNotFound), errors.Is(err, store.ErrAccountRestricted):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "ACCOUNT_RESTRICTED", "account is restricted", nil)
|
||||
case errors.Is(err, store.ErrPendingOrderExists):
|
||||
writeError(w, r, http.StatusConflict, 30040, "PENDING_ORDER_EXISTS", "unfinished order exists", nil)
|
||||
case errors.Is(err, store.ErrNoTaskAvailable):
|
||||
writeError(w, r, http.StatusServiceUnavailable, 30053, "NO_TASK_AVAILABLE", "no task is currently available", nil)
|
||||
case errors.Is(err, store.ErrOrderNotFound):
|
||||
writeError(w, r, http.StatusNotFound, 30044, "ORDER_NOT_FOUND", "task order not found", nil)
|
||||
case errors.Is(err, store.ErrOrderState):
|
||||
writeError(w, r, http.StatusConflict, 30040, "ORDER_STATE_INVALID", "task order state does not allow this operation", nil)
|
||||
case errors.Is(err, store.ErrOrderExpired):
|
||||
writeError(w, r, http.StatusConflict, 30040, "ORDER_EXPIRED", "task order has expired", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/xtea"
|
||||
)
|
||||
|
||||
type userID int64
|
||||
|
||||
const defaultUIDKey = "dkXxWLwqUNRfYmdXRzOkZw=="
|
||||
|
||||
var (
|
||||
uidCodecMu sync.RWMutex
|
||||
uidCipher *xtea.Cipher
|
||||
)
|
||||
|
||||
func init() {
|
||||
if err := SetUIDKey(defaultUIDKey); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func SetUIDKey(encoded string) error {
|
||||
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid uid key: %w", err)
|
||||
}
|
||||
cipher, err := xtea.NewCipher(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid uid key: %w", err)
|
||||
}
|
||||
uidCodecMu.Lock()
|
||||
uidCipher = cipher
|
||||
uidCodecMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (id *userID) UnmarshalJSON(raw []byte) error {
|
||||
value, err := parseUserIDJSON(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*id = userID(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
type optionalUserID struct {
|
||||
value int64
|
||||
set bool
|
||||
}
|
||||
|
||||
func (id *optionalUserID) UnmarshalJSON(raw []byte) error {
|
||||
if strings.TrimSpace(string(raw)) == "null" {
|
||||
return nil
|
||||
}
|
||||
value, err := parseUserIDJSON(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id.set = true
|
||||
id.value = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (id optionalUserID) ptr() *int64 {
|
||||
if !id.set {
|
||||
return nil
|
||||
}
|
||||
return &id.value
|
||||
}
|
||||
|
||||
func parseUserIDJSON(raw []byte) (int64, error) {
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err == nil {
|
||||
return parseUserID(text)
|
||||
}
|
||||
return 0, fmt.Errorf("user id must be a string")
|
||||
}
|
||||
|
||||
func parseUserID(value string) (int64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, fmt.Errorf("empty user id")
|
||||
}
|
||||
if strings.HasPrefix(value, "usr") {
|
||||
decoded, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(strings.TrimPrefix(value, "usr"))
|
||||
if err != nil || len(decoded) < 8 {
|
||||
return 0, fmt.Errorf("invalid user id")
|
||||
}
|
||||
return decryptUID(decoded[:8]), nil
|
||||
}
|
||||
return strconv.ParseInt(value, 10, 64)
|
||||
}
|
||||
|
||||
func formatUserID(id int64) string {
|
||||
if id == 0 {
|
||||
return ""
|
||||
}
|
||||
encoded := encryptUID(id)
|
||||
return "usr" + base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(encoded)
|
||||
}
|
||||
|
||||
func parseUserIDList(values []userID) []int64 {
|
||||
items := make([]int64, 0, len(values))
|
||||
seen := make(map[int64]bool, len(values))
|
||||
for _, value := range values {
|
||||
id := int64(value)
|
||||
if id == 0 || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
items = append(items, id)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func decryptUID(encoded []byte) int64 {
|
||||
dst := make([]byte, 8)
|
||||
uidCodecMu.RLock()
|
||||
cipher := uidCipher
|
||||
uidCodecMu.RUnlock()
|
||||
cipher.Decrypt(dst, encoded[:8])
|
||||
return int64(binary.LittleEndian.Uint64(dst))
|
||||
}
|
||||
|
||||
func encryptUID(id int64) []byte {
|
||||
src := make([]byte, 8)
|
||||
dst := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint64(src, uint64(id))
|
||||
uidCodecMu.RLock()
|
||||
cipher := uidCipher
|
||||
uidCodecMu.RUnlock()
|
||||
cipher.Encrypt(dst, src)
|
||||
return dst
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserIDJSONAcceptsIMUserID(t *testing.T) {
|
||||
var body struct {
|
||||
RecorderUserID userID `json:"recorder_user_id"`
|
||||
AttendeeUserIDs []userID `json:"attendee_user_ids"`
|
||||
}
|
||||
raw := []byte(`{"recorder_user_id":"usrag7w9xJ6hto","attendee_user_ids":["usr1RqTFgHSenM"]}`)
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if body.RecorderUserID == 0 || len(body.AttendeeUserIDs) != 1 || body.AttendeeUserIDs[0] == 0 {
|
||||
t.Fatalf("decoded zero ids: %+v", body)
|
||||
}
|
||||
if got := formatUserID(int64(body.RecorderUserID)); got != "usrag7w9xJ6hto" {
|
||||
t.Fatalf("roundtrip recorder=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserIDCodecMatchesIMServer(t *testing.T) {
|
||||
const (
|
||||
publicID = "usr3rZjh8TD_34"
|
||||
dbID = int64(1574592702383656960)
|
||||
)
|
||||
got, err := parseUserID(publicID)
|
||||
if err != nil {
|
||||
t.Fatalf("parseUserID: %v", err)
|
||||
}
|
||||
if got != dbID {
|
||||
t.Fatalf("parseUserID(%q)=%d, want %d", publicID, got, dbID)
|
||||
}
|
||||
if formatted := formatUserID(dbID); formatted != publicID {
|
||||
t.Fatalf("formatUserID(%d)=%q, want %q", dbID, formatted, publicID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserIDJSONRejectsNumericID(t *testing.T) {
|
||||
var id userID
|
||||
if err := json.Unmarshal([]byte(`1573862062872858624`), &id); err == nil {
|
||||
t.Fatal("expected numeric user id to be rejected")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user