feat(ops): add tenant admin user management module
Adds /admin-users CRUD on the ops backend, covering listing, plan/role/KOL toggles, subscription expiry, status flips and password resets, with a configurable default plan code. The ops console exposes a new top-level "用户管理" view and reorganises the sidebar so 操作员管理 and 审计日志 move under a "系统设置" group; AccountsView is renamed accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,9 @@
|
|||||||
theme="light"
|
theme="light"
|
||||||
mode="inline"
|
mode="inline"
|
||||||
:selected-keys="selectedKeys"
|
:selected-keys="selectedKeys"
|
||||||
|
:open-keys="openKeys"
|
||||||
:items="menuItems"
|
:items="menuItems"
|
||||||
|
@update:open-keys="onOpenKeysChange"
|
||||||
@click="onMenuClick"
|
@click="onMenuClick"
|
||||||
/>
|
/>
|
||||||
</a-layout-sider>
|
</a-layout-sider>
|
||||||
@@ -44,12 +46,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import {
|
||||||
|
AuditOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
} from "@ant-design/icons-vue";
|
||||||
|
import type { ItemType } from "ant-design-vue";
|
||||||
|
import { computed, h, onMounted, ref } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuLeaf {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -61,15 +70,47 @@ const auth = useAuthStore();
|
|||||||
|
|
||||||
const collapsed = ref(false);
|
const collapsed = ref(false);
|
||||||
|
|
||||||
const menuItems = computed<MenuItem[]>(() => [
|
const menuLeaves: MenuLeaf[] = [
|
||||||
{ key: "/accounts", label: "账号管理", path: "/accounts" },
|
{ key: "/admin-users", label: "用户管理", path: "/admin-users" },
|
||||||
|
{ key: "/accounts", label: "操作员管理", path: "/accounts" },
|
||||||
{ key: "/audits", label: "审计日志", path: "/audits" },
|
{ key: "/audits", label: "审计日志", path: "/audits" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const menuItems = computed<ItemType[]>(() => [
|
||||||
|
{
|
||||||
|
key: "/admin-users",
|
||||||
|
label: "用户管理",
|
||||||
|
icon: () => h(TeamOutlined),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "system",
|
||||||
|
label: "系统设置",
|
||||||
|
icon: () => h(SettingOutlined),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "/accounts",
|
||||||
|
label: "操作员管理",
|
||||||
|
icon: () => h(SafetyCertificateOutlined),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "/audits",
|
||||||
|
label: "审计日志",
|
||||||
|
icon: () => h(AuditOutlined),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const openKeys = ref<string[]>(["system"]);
|
||||||
|
|
||||||
|
function onOpenKeysChange(keys: string[]) {
|
||||||
|
openKeys.value = keys;
|
||||||
|
}
|
||||||
|
|
||||||
const selectedKeys = computed(() => [route.path]);
|
const selectedKeys = computed(() => [route.path]);
|
||||||
|
|
||||||
function onMenuClick(info: { key: string | number }) {
|
function onMenuClick(info: { key: string | number }) {
|
||||||
const item = menuItems.value.find((m) => m.key === info.key);
|
const item = menuLeaves.find((m) => m.key === info.key);
|
||||||
if (item) void router.push(item.path);
|
if (item) void router.push(item.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -76,6 +77,7 @@ bindUnauthorizedHandler(() => {
|
|||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
|||||||
@@ -17,12 +17,18 @@ export const router = createRouter({
|
|||||||
component: () => import("@/layouts/AppShell.vue"),
|
component: () => import("@/layouts/AppShell.vue"),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
children: [
|
children: [
|
||||||
{ path: "", redirect: "/accounts" },
|
{ path: "", redirect: "/admin-users" },
|
||||||
|
{
|
||||||
|
path: "admin-users",
|
||||||
|
name: "admin-users",
|
||||||
|
component: () => import("@/views/AdminUsersView.vue"),
|
||||||
|
meta: { title: "用户管理" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "accounts",
|
path: "accounts",
|
||||||
name: "accounts",
|
name: "accounts",
|
||||||
component: () => import("@/views/AccountsView.vue"),
|
component: () => import("@/views/AccountsView.vue"),
|
||||||
meta: { title: "账号管理" },
|
meta: { title: "操作员管理" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "audits",
|
path: "audits",
|
||||||
@@ -59,7 +65,7 @@ router.beforeEach((to) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (to.name === "login" && auth.isAuthenticated) {
|
if (to.name === "login" && auth.isAuthenticated) {
|
||||||
return { name: "accounts" };
|
return { name: "admin-users" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ body {
|
|||||||
|
|
||||||
.ops-shell-content {
|
.ops-shell-content {
|
||||||
padding: 24px 32px;
|
padding: 24px 32px;
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<h2 class="ops-page-title">账号管理</h2>
|
<h2 class="ops-page-title">操作员管理</h2>
|
||||||
|
|
||||||
<div class="ops-card">
|
<div class="ops-card">
|
||||||
<div class="ops-toolbar">
|
<div class="ops-toolbar">
|
||||||
|
|||||||
@@ -0,0 +1,737 @@
|
|||||||
|
<template>
|
||||||
|
<h2 class="ops-page-title">用户管理</h2>
|
||||||
|
|
||||||
|
<div class="ops-card">
|
||||||
|
<div class="ops-toolbar admin-users-toolbar">
|
||||||
|
<a-input
|
||||||
|
v-model:value="filter.keyword"
|
||||||
|
class="admin-users-search"
|
||||||
|
placeholder="搜索姓名 / 手机号 / 邮箱"
|
||||||
|
allow-clear
|
||||||
|
@press-enter="reload"
|
||||||
|
@change="onKeywordChange"
|
||||||
|
/>
|
||||||
|
<a-select
|
||||||
|
v-model:value="filter.status"
|
||||||
|
class="admin-users-status"
|
||||||
|
:options="statusOptions"
|
||||||
|
placeholder="状态"
|
||||||
|
allow-clear
|
||||||
|
@change="reload"
|
||||||
|
/>
|
||||||
|
<a-button @click="reload">刷新</a-button>
|
||||||
|
<span class="admin-users-toolbar__spacer" />
|
||||||
|
<a-button type="primary" @click="openCreate">新增用户</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="rows"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
row-key="id"
|
||||||
|
@change="onTableChange"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'identity'">
|
||||||
|
<div class="identity-cell">
|
||||||
|
<strong>{{ displayUserName(record) }}</strong>
|
||||||
|
<span>#{{ record.id }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'phone'">
|
||||||
|
{{ record.phone }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'email'">
|
||||||
|
{{ record.email || "—" }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'status'">
|
||||||
|
<a-tag :color="record.status === 'active' ? 'green' : 'red'">
|
||||||
|
{{ record.status === "active" ? "启用" : "停用" }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'role'">
|
||||||
|
<a-tag :color="roleColor(record.tenant_role)">
|
||||||
|
{{ roleLabel(record.tenant_role) }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'kol'">
|
||||||
|
<a-space>
|
||||||
|
<a-tag :color="kolColor(record.kol_status)">
|
||||||
|
{{ kolLabel(record.kol_status) }}
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-if="record.kol_market_enabled" color="green">市场展示</a-tag>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'plan'">
|
||||||
|
<div class="plan-cell">
|
||||||
|
<a-space>
|
||||||
|
<a-tag>{{ record.plan_name || record.plan_code || "—" }}</a-tag>
|
||||||
|
<a-tag :color="record.subscription_status === 'active' ? 'green' : 'default'">
|
||||||
|
{{ record.subscription_status || "—" }}
|
||||||
|
</a-tag>
|
||||||
|
</a-space>
|
||||||
|
<span :class="['plan-cell__expiry', { 'plan-cell__expiry--expired': isExpired(record.subscription_end_at) }]">
|
||||||
|
{{ formatSubscriptionEnd(record.subscription_end_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'tenant'">
|
||||||
|
<span v-if="record.tenant_id">#{{ record.tenant_id }}</span>
|
||||||
|
<span v-else class="muted-text">—</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'created_at'">
|
||||||
|
{{ formatDate(record.created_at) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'actions'">
|
||||||
|
<a-space>
|
||||||
|
<a @click="openEdit(record)">编辑</a>
|
||||||
|
<a @click="openKol(record)">KOL身份</a>
|
||||||
|
<a-popconfirm
|
||||||
|
title="确认恢复当前套餐默认额度?文章生成次数和 AI 点数会恢复到套餐默认值。"
|
||||||
|
:ok-button-props="{ danger: true, loading: resetUsageLoadingId === record.id }"
|
||||||
|
@confirm="resetPlanUsage(record)"
|
||||||
|
>
|
||||||
|
<a>恢复额度</a>
|
||||||
|
</a-popconfirm>
|
||||||
|
<a @click="openResetPassword(record)">重置密码</a>
|
||||||
|
<a-popconfirm
|
||||||
|
:title="record.status === 'active' ? '确认停用该用户?' : '确认启用该用户?'"
|
||||||
|
@confirm="toggleStatus(record)"
|
||||||
|
>
|
||||||
|
<a>{{ record.status === "active" ? "停用" : "启用" }}</a>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-modal
|
||||||
|
v-model:open="createOpen"
|
||||||
|
title="新增用户"
|
||||||
|
:confirm-loading="createLoading"
|
||||||
|
@ok="submitCreate"
|
||||||
|
@cancel="resetCreate"
|
||||||
|
>
|
||||||
|
<a-form layout="vertical" :model="createForm">
|
||||||
|
<a-form-item label="手机号" required>
|
||||||
|
<a-input v-model:value="createForm.phone" placeholder="13800138000" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="姓名">
|
||||||
|
<a-input v-model:value="createForm.name" placeholder="可选" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="邮箱">
|
||||||
|
<a-input v-model:value="createForm.email" placeholder="可选" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="初始密码" required help="至少 8 位">
|
||||||
|
<a-input-password v-model:value="createForm.password" placeholder="≥ 8 位" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="用户角色" required>
|
||||||
|
<a-select v-model:value="createForm.role" :options="roleOptions" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="初始套餐" required>
|
||||||
|
<a-select v-model:value="createForm.plan_code" :options="planOptions" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
|
<a-modal
|
||||||
|
v-model:open="editOpen"
|
||||||
|
:title="editTarget ? `编辑 ${displayUserName(editTarget)}` : '编辑用户'"
|
||||||
|
:confirm-loading="editLoading"
|
||||||
|
@ok="submitEdit"
|
||||||
|
@cancel="editOpen = false"
|
||||||
|
>
|
||||||
|
<a-form layout="vertical" :model="editForm">
|
||||||
|
<a-form-item label="手机号" required>
|
||||||
|
<a-input v-model:value="editForm.phone" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="姓名">
|
||||||
|
<a-input v-model:value="editForm.name" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="邮箱">
|
||||||
|
<a-input v-model:value="editForm.email" placeholder="可选" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="用户角色" required>
|
||||||
|
<a-select v-model:value="editForm.role" :options="roleOptions" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="套餐" required>
|
||||||
|
<a-select v-model:value="editForm.plan_code" :options="planOptions" @change="onEditPlanChange" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="会员到期日" required>
|
||||||
|
<a-date-picker
|
||||||
|
v-model:value="editForm.subscription_end_date"
|
||||||
|
class="admin-users-date-picker"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:allow-clear="false"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
|
<a-modal
|
||||||
|
v-model:open="kolOpen"
|
||||||
|
:title="kolTarget ? `设置 ${displayUserName(kolTarget)} 的 KOL 身份` : 'KOL 身份'"
|
||||||
|
:confirm-loading="kolLoading"
|
||||||
|
@ok="submitKol"
|
||||||
|
@cancel="kolOpen = false"
|
||||||
|
>
|
||||||
|
<a-form layout="vertical" :model="kolForm">
|
||||||
|
<a-form-item label="KOL 身份" required>
|
||||||
|
<a-switch v-model:checked="kolForm.enabled" checked-children="开通" un-checked-children="停用" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="KOL 昵称">
|
||||||
|
<a-input v-model:value="kolForm.display_name" placeholder="默认使用姓名或手机号" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="市场展示">
|
||||||
|
<a-switch
|
||||||
|
v-model:checked="kolForm.market_enabled"
|
||||||
|
:disabled="!kolForm.enabled"
|
||||||
|
checked-children="展示"
|
||||||
|
un-checked-children="不展示"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
|
<a-modal
|
||||||
|
v-model:open="resetOpen"
|
||||||
|
:title="resetTarget ? `重置 ${displayUserName(resetTarget)} 的密码` : '重置密码'"
|
||||||
|
:confirm-loading="resetLoading"
|
||||||
|
@ok="submitResetPassword"
|
||||||
|
@cancel="resetOpen = false"
|
||||||
|
>
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item label="新密码" required help="至少 8 位">
|
||||||
|
<a-input-password v-model:value="resetPasswordValue" placeholder="≥ 8 位" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TableColumnsType, TablePaginationConfig } from "ant-design-vue";
|
||||||
|
import { message } from "ant-design-vue";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
|
|
||||||
|
import { OpsApiError, http } from "@/lib/http";
|
||||||
|
|
||||||
|
interface AdminUserRow {
|
||||||
|
id: number;
|
||||||
|
email: string | null;
|
||||||
|
phone: string;
|
||||||
|
name: string | null;
|
||||||
|
status: string;
|
||||||
|
tenant_id: number | null;
|
||||||
|
tenant_name: string | null;
|
||||||
|
tenant_status: string | null;
|
||||||
|
tenant_role: string | null;
|
||||||
|
primary_workspace_id: number | null;
|
||||||
|
plan_code: string | null;
|
||||||
|
plan_name: string | null;
|
||||||
|
subscription_status: string | null;
|
||||||
|
subscription_end_at: string | null;
|
||||||
|
kol_profile_id: number | null;
|
||||||
|
kol_display_name: string | null;
|
||||||
|
kol_status: string | null;
|
||||||
|
kol_market_enabled: boolean | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlanItem {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
duration_secs: number;
|
||||||
|
article_limit: number;
|
||||||
|
ai_points_monthly: number;
|
||||||
|
brand_limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleItem {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListResult {
|
||||||
|
items: AdminUserRow[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResetPlanUsageResult {
|
||||||
|
user: AdminUserRow;
|
||||||
|
reset: {
|
||||||
|
article_reservations: number;
|
||||||
|
article_amount: number;
|
||||||
|
ai_point_reservations: number;
|
||||||
|
ai_points_amount: number;
|
||||||
|
ai_point_usage_logs: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = reactive({
|
||||||
|
keyword: "",
|
||||||
|
status: undefined as string | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: "启用", value: "active" },
|
||||||
|
{ label: "停用", value: "disabled" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns: TableColumnsType<AdminUserRow> = [
|
||||||
|
{ title: "用户", key: "identity", width: 180 },
|
||||||
|
{ title: "手机号", key: "phone", width: 160 },
|
||||||
|
{ title: "邮箱", key: "email", width: 220 },
|
||||||
|
{ title: "状态", key: "status", width: 100 },
|
||||||
|
{ title: "角色", key: "role", width: 120 },
|
||||||
|
{ title: "套餐", key: "plan", width: 180 },
|
||||||
|
{ title: "KOL", key: "kol", width: 180 },
|
||||||
|
{ title: "空间", key: "tenant", width: 100 },
|
||||||
|
{ title: "创建时间", key: "created_at", width: 180 },
|
||||||
|
{ title: "操作", key: "actions", width: 300 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = ref<AdminUserRow[]>([]);
|
||||||
|
const plans = ref<PlanItem[]>([]);
|
||||||
|
const roles = ref<RoleItem[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const page = ref(1);
|
||||||
|
const size = ref(20);
|
||||||
|
const total = ref(0);
|
||||||
|
|
||||||
|
const pagination = computed<TablePaginationConfig>(() => ({
|
||||||
|
current: page.value,
|
||||||
|
pageSize: size.value,
|
||||||
|
total: total.value,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: ["10", "20", "50", "100"],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const planOptions = computed(() =>
|
||||||
|
plans.value.map((plan) => ({
|
||||||
|
label: `${plan.name} (${plan.code})`,
|
||||||
|
value: plan.code,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const roleOptions = computed(() =>
|
||||||
|
roles.value.map((role) => ({
|
||||||
|
label: role.name,
|
||||||
|
value: role.code,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
let keywordTimer: number | null = null;
|
||||||
|
|
||||||
|
function onKeywordChange() {
|
||||||
|
if (keywordTimer) window.clearTimeout(keywordTimer);
|
||||||
|
keywordTimer = window.setTimeout(() => {
|
||||||
|
page.value = 1;
|
||||||
|
void reload();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await http.get<ListResult>("/admin-users", {
|
||||||
|
keyword: filter.keyword || undefined,
|
||||||
|
status: filter.status || undefined,
|
||||||
|
page: page.value,
|
||||||
|
size: size.value,
|
||||||
|
});
|
||||||
|
rows.value = result.items;
|
||||||
|
total.value = result.total;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OpsApiError) message.error(error.detail || error.message);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMeta() {
|
||||||
|
try {
|
||||||
|
const [planResult, roleResult] = await Promise.all([
|
||||||
|
http.get<PlanItem[]>("/plans"),
|
||||||
|
http.get<RoleItem[]>("/roles"),
|
||||||
|
]);
|
||||||
|
plans.value = planResult;
|
||||||
|
roles.value = roleResult;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OpsApiError) message.error(error.detail || error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTableChange(pag: TablePaginationConfig) {
|
||||||
|
page.value = pag.current ?? 1;
|
||||||
|
size.value = pag.pageSize ?? 20;
|
||||||
|
void reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
const createOpen = ref(false);
|
||||||
|
const createLoading = ref(false);
|
||||||
|
const createForm = reactive({
|
||||||
|
phone: "",
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
role: "tenant_admin",
|
||||||
|
plan_code: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetCreate() {
|
||||||
|
createForm.phone = "";
|
||||||
|
createForm.name = "";
|
||||||
|
createForm.email = "";
|
||||||
|
createForm.password = "";
|
||||||
|
createForm.role = "tenant_admin";
|
||||||
|
createForm.plan_code = defaultPlanCode();
|
||||||
|
createOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
resetCreate();
|
||||||
|
createOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate() {
|
||||||
|
const phone = normalizePhone(createForm.phone);
|
||||||
|
if (!phone) {
|
||||||
|
message.warning("请输入有效手机号");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (createForm.password.length < 8) {
|
||||||
|
message.warning("初始密码至少 8 位");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!createForm.role || !createForm.plan_code) {
|
||||||
|
message.warning("请选择用户角色和套餐");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createLoading.value = true;
|
||||||
|
try {
|
||||||
|
await http.post("/admin-users", {
|
||||||
|
phone,
|
||||||
|
name: createForm.name.trim(),
|
||||||
|
email: createForm.email.trim(),
|
||||||
|
password: createForm.password,
|
||||||
|
role: createForm.role,
|
||||||
|
plan_code: createForm.plan_code,
|
||||||
|
});
|
||||||
|
message.success("新建成功");
|
||||||
|
resetCreate();
|
||||||
|
void reload();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OpsApiError) message.error(error.detail || error.message);
|
||||||
|
} finally {
|
||||||
|
createLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editOpen = ref(false);
|
||||||
|
const editLoading = ref(false);
|
||||||
|
const editTarget = ref<AdminUserRow | null>(null);
|
||||||
|
const editForm = reactive({
|
||||||
|
phone: "",
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
role: "tenant_admin",
|
||||||
|
plan_code: "",
|
||||||
|
subscription_end_date: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
function openEdit(row: AdminUserRow) {
|
||||||
|
editTarget.value = row;
|
||||||
|
editForm.phone = row.phone;
|
||||||
|
editForm.name = row.name ?? "";
|
||||||
|
editForm.email = row.email ?? "";
|
||||||
|
editForm.role = row.tenant_role ?? "tenant_admin";
|
||||||
|
editForm.plan_code = row.plan_code ?? defaultPlanCode();
|
||||||
|
editForm.subscription_end_date = row.subscription_end_at
|
||||||
|
? dayjs(row.subscription_end_at).format("YYYY-MM-DD")
|
||||||
|
: estimatePlanEndDate(editForm.plan_code);
|
||||||
|
editOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEditPlanChange(value: unknown) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
editForm.subscription_end_date = estimatePlanEndDate(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEdit() {
|
||||||
|
if (!editTarget.value) return;
|
||||||
|
const phone = normalizePhone(editForm.phone);
|
||||||
|
if (!phone) {
|
||||||
|
message.warning("请输入有效手机号");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editForm.role || !editForm.plan_code) {
|
||||||
|
message.warning("请选择用户角色和套餐");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editForm.subscription_end_date) {
|
||||||
|
message.warning("请选择会员到期日");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const originalEndDate = editTarget.value.subscription_end_at
|
||||||
|
? dayjs(editTarget.value.subscription_end_at).format("YYYY-MM-DD")
|
||||||
|
: "";
|
||||||
|
const subscriptionEndAt = dayjs(editForm.subscription_end_date).endOf("day").toISOString();
|
||||||
|
editLoading.value = true;
|
||||||
|
try {
|
||||||
|
await http.patch(`/admin-users/${editTarget.value.id}`, {
|
||||||
|
phone,
|
||||||
|
name: editForm.name.trim(),
|
||||||
|
email: editForm.email.trim(),
|
||||||
|
});
|
||||||
|
if (editForm.plan_code !== editTarget.value.plan_code) {
|
||||||
|
await http.post(`/admin-users/${editTarget.value.id}/plan`, {
|
||||||
|
plan_code: editForm.plan_code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (editForm.role !== editTarget.value.tenant_role) {
|
||||||
|
await http.post(`/admin-users/${editTarget.value.id}/role`, {
|
||||||
|
role: editForm.role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (editForm.subscription_end_date !== originalEndDate) {
|
||||||
|
await http.post(`/admin-users/${editTarget.value.id}/subscription-expiry`, {
|
||||||
|
end_at: subscriptionEndAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
message.success("用户设置已更新");
|
||||||
|
editOpen.value = false;
|
||||||
|
void reload();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OpsApiError) message.error(error.detail || error.message);
|
||||||
|
} finally {
|
||||||
|
editLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetUsageLoadingId = ref<number | null>(null);
|
||||||
|
|
||||||
|
async function resetPlanUsage(row: AdminUserRow) {
|
||||||
|
resetUsageLoadingId.value = row.id;
|
||||||
|
try {
|
||||||
|
const result = await http.post<ResetPlanUsageResult>(`/admin-users/${row.id}/reset-plan-usage`);
|
||||||
|
message.success(`已恢复到套餐默认额度:${planQuotaLabel(result.user.plan_code)}`);
|
||||||
|
rows.value = rows.value.map((item) => (item.id === row.id ? result.user : item));
|
||||||
|
void reload();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OpsApiError) message.error(error.detail || error.message);
|
||||||
|
} finally {
|
||||||
|
resetUsageLoadingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetOpen = ref(false);
|
||||||
|
const resetLoading = ref(false);
|
||||||
|
const resetTarget = ref<AdminUserRow | null>(null);
|
||||||
|
const resetPasswordValue = ref("");
|
||||||
|
|
||||||
|
function openResetPassword(row: AdminUserRow) {
|
||||||
|
resetTarget.value = row;
|
||||||
|
resetPasswordValue.value = "";
|
||||||
|
resetOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitResetPassword() {
|
||||||
|
if (!resetTarget.value) return;
|
||||||
|
if (resetPasswordValue.value.length < 8) {
|
||||||
|
message.warning("新密码至少 8 位");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetLoading.value = true;
|
||||||
|
try {
|
||||||
|
await http.post(`/admin-users/${resetTarget.value.id}/password`, {
|
||||||
|
password: resetPasswordValue.value,
|
||||||
|
});
|
||||||
|
message.success("密码已重置");
|
||||||
|
resetOpen.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OpsApiError) message.error(error.detail || error.message);
|
||||||
|
} finally {
|
||||||
|
resetLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kolOpen = ref(false);
|
||||||
|
const kolLoading = ref(false);
|
||||||
|
const kolTarget = ref<AdminUserRow | null>(null);
|
||||||
|
const kolForm = reactive({
|
||||||
|
enabled: false,
|
||||||
|
display_name: "",
|
||||||
|
market_enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function openKol(row: AdminUserRow) {
|
||||||
|
kolTarget.value = row;
|
||||||
|
kolForm.enabled = row.kol_status === "active";
|
||||||
|
kolForm.display_name = row.kol_display_name ?? displayUserName(row);
|
||||||
|
kolForm.market_enabled = Boolean(row.kol_market_enabled);
|
||||||
|
kolOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitKol() {
|
||||||
|
if (!kolTarget.value) return;
|
||||||
|
kolLoading.value = true;
|
||||||
|
try {
|
||||||
|
await http.post(`/admin-users/${kolTarget.value.id}/kol`, {
|
||||||
|
enabled: kolForm.enabled,
|
||||||
|
display_name: kolForm.display_name.trim(),
|
||||||
|
market_enabled: kolForm.enabled ? kolForm.market_enabled : false,
|
||||||
|
});
|
||||||
|
message.success("KOL 身份已更新");
|
||||||
|
kolOpen.value = false;
|
||||||
|
void reload();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OpsApiError) message.error(error.detail || error.message);
|
||||||
|
} finally {
|
||||||
|
kolLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleStatus(row: AdminUserRow) {
|
||||||
|
const next = row.status === "active" ? "disabled" : "active";
|
||||||
|
try {
|
||||||
|
await http.post(`/admin-users/${row.id}/status`, { status: next });
|
||||||
|
message.success(next === "active" ? "已启用" : "已停用");
|
||||||
|
void reload();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OpsApiError) message.error(error.detail || error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePhone(value: string): string {
|
||||||
|
const phone = value.trim().replace(/[\s-]/g, "");
|
||||||
|
return /^\+?\d{6,20}$/.test(phone) ? phone : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayUserName(row: AdminUserRow): string {
|
||||||
|
return row.name || row.phone || row.email || `#${row.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPlanCode(): string {
|
||||||
|
return plans.value[0]?.code ?? "free";
|
||||||
|
}
|
||||||
|
|
||||||
|
function planQuotaLabel(planCode: string | null): string {
|
||||||
|
const plan = plans.value.find((item) => item.code === planCode);
|
||||||
|
if (!plan) return "文章和 AI 点数按当前套餐默认值重置";
|
||||||
|
return `文章 ${plan.article_limit} 次,AI 点数 ${plan.ai_points_monthly} 点`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimatePlanEndDate(planCode: string): string {
|
||||||
|
const durationSecs = plans.value.find((plan) => plan.code === planCode)?.duration_secs;
|
||||||
|
if (!durationSecs || durationSecs <= 0) {
|
||||||
|
return dayjs().format("YYYY-MM-DD");
|
||||||
|
}
|
||||||
|
return dayjs().add(durationSecs, "second").format("YYYY-MM-DD");
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleLabel(role: string | null): string {
|
||||||
|
return roles.value.find((item) => item.code === role)?.name ?? role ?? "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleColor(role: string | null): string {
|
||||||
|
if (role === "tenant_admin") return "blue";
|
||||||
|
if (role === "editor") return "purple";
|
||||||
|
if (role === "viewer") return "default";
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
function kolLabel(status: string | null): string {
|
||||||
|
if (status === "active") return "已开通";
|
||||||
|
if (status === "suspended") return "已停用";
|
||||||
|
if (status === "closed") return "已关闭";
|
||||||
|
return "未开通";
|
||||||
|
}
|
||||||
|
|
||||||
|
function kolColor(status: string | null): string {
|
||||||
|
if (status === "active") return "gold";
|
||||||
|
if (status === "suspended") return "default";
|
||||||
|
if (status === "closed") return "red";
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSubscriptionEnd(value: string | null): string {
|
||||||
|
if (!value) return "到期:—";
|
||||||
|
return `到期:${dayjs(value).format("YYYY-MM-DD")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpired(value: string | null): boolean {
|
||||||
|
return Boolean(value && dayjs(value).isBefore(dayjs()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
return dayjs(value).format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadMeta();
|
||||||
|
void reload();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-users-toolbar {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-search {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-status {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-date-picker {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-toolbar__spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-cell__expiry {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-cell__expiry--expired {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-cell span,
|
||||||
|
.muted-text {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -100,7 +100,7 @@ async function onSubmit() {
|
|||||||
try {
|
try {
|
||||||
await auth.login(form.username.trim(), form.password);
|
await auth.login(form.username.trim(), form.password);
|
||||||
const redirect =
|
const redirect =
|
||||||
typeof route.query.redirect === "string" ? route.query.redirect : "/accounts";
|
typeof route.query.redirect === "string" ? route.query.redirect : "/admin-users";
|
||||||
void router.replace(redirect);
|
void router.replace(redirect);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof OpsApiError) {
|
if (error instanceof OpsApiError) {
|
||||||
|
|||||||
@@ -25,3 +25,6 @@ default_admin:
|
|||||||
display_name: 系统管理员
|
display_name: 系统管理员
|
||||||
email: ""
|
email: ""
|
||||||
password: "" # 由 OPS_DEFAULT_ADMIN_PASSWORD 环境变量注入(仅在 operator_accounts 为空时生效)
|
password: "" # 由 OPS_DEFAULT_ADMIN_PASSWORD 环境变量注入(仅在 operator_accounts 为空时生效)
|
||||||
|
|
||||||
|
admin_users:
|
||||||
|
default_plan_code: free
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
opsconfig "github.com/geo-platform/tenant-api/internal/ops/config"
|
|
||||||
"github.com/geo-platform/tenant-api/internal/ops/app"
|
"github.com/geo-platform/tenant-api/internal/ops/app"
|
||||||
|
opsconfig "github.com/geo-platform/tenant-api/internal/ops/config"
|
||||||
"github.com/geo-platform/tenant-api/internal/ops/repository"
|
"github.com/geo-platform/tenant-api/internal/ops/repository"
|
||||||
"github.com/geo-platform/tenant-api/internal/ops/transport"
|
"github.com/geo-platform/tenant-api/internal/ops/transport"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/observability"
|
"github.com/geo-platform/tenant-api/internal/shared/observability"
|
||||||
@@ -45,12 +45,14 @@ func main() {
|
|||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
accountsRepo := repository.NewAccountRepository(pool)
|
accountsRepo := repository.NewAccountRepository(pool)
|
||||||
|
adminUsersRepo := repository.NewAdminUserRepository(pool)
|
||||||
auditsRepo := repository.NewAuditRepository(pool)
|
auditsRepo := repository.NewAuditRepository(pool)
|
||||||
|
|
||||||
auditSvc := app.NewAuditService(auditsRepo, logger)
|
auditSvc := app.NewAuditService(auditsRepo, logger)
|
||||||
issuer := app.NewTokenIssuer(cfg.JWT.Secret, cfg.JWT.AccessTTL)
|
issuer := app.NewTokenIssuer(cfg.JWT.Secret, cfg.JWT.AccessTTL)
|
||||||
authSvc := app.NewAuthService(accountsRepo, auditSvc, issuer)
|
authSvc := app.NewAuthService(accountsRepo, auditSvc, issuer)
|
||||||
accountSvc := app.NewAccountService(accountsRepo, auditSvc)
|
accountSvc := app.NewAccountService(accountsRepo, auditSvc)
|
||||||
|
adminUserSvc := app.NewAdminUserService(adminUsersRepo, auditSvc, cfg.AdminUsers.DefaultPlanCode)
|
||||||
|
|
||||||
if err := app.SeedDefaultAdmin(ctx, accountsRepo, app.DefaultAdminSeed{
|
if err := app.SeedDefaultAdmin(ctx, accountsRepo, app.DefaultAdminSeed{
|
||||||
Username: cfg.DefaultAdmin.Username,
|
Username: cfg.DefaultAdmin.Username,
|
||||||
@@ -67,13 +69,14 @@ func main() {
|
|||||||
engine := gin.New()
|
engine := gin.New()
|
||||||
|
|
||||||
transport.RegisterRoutes(transport.Deps{
|
transport.RegisterRoutes(transport.Deps{
|
||||||
Engine: engine,
|
Engine: engine,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
DB: pool,
|
DB: pool,
|
||||||
Issuer: issuer,
|
Issuer: issuer,
|
||||||
Auth: authSvc,
|
Auth: authSvc,
|
||||||
Accounts: accountSvc,
|
Accounts: accountSvc,
|
||||||
Audits: auditSvc,
|
AdminUsers: adminUserSvc,
|
||||||
|
Audits: auditSvc,
|
||||||
})
|
})
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
||||||
|
|||||||
@@ -25,3 +25,6 @@ default_admin:
|
|||||||
display_name: 系统管理员
|
display_name: 系统管理员
|
||||||
email: ""
|
email: ""
|
||||||
password: "liang0920" # 必须通过 OPS_DEFAULT_ADMIN_PASSWORD 提供(首次启动且表为空时使用)
|
password: "liang0920" # 必须通过 OPS_DEFAULT_ADMIN_PASSWORD 提供(首次启动且表为空时使用)
|
||||||
|
|
||||||
|
admin_users:
|
||||||
|
default_plan_code: free
|
||||||
|
|||||||
@@ -0,0 +1,610 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/mail"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/ops/repository"
|
||||||
|
sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionAdminUserCreate = "admin_user.create"
|
||||||
|
ActionAdminUserUpdateProfile = "admin_user.update_profile"
|
||||||
|
ActionAdminUserChangePlan = "admin_user.change_plan"
|
||||||
|
ActionAdminUserChangeRole = "admin_user.change_role"
|
||||||
|
ActionAdminUserChangeExpiry = "admin_user.change_expiry"
|
||||||
|
ActionAdminUserResetUsage = "admin_user.reset_usage"
|
||||||
|
ActionAdminUserSetKOL = "admin_user.set_kol"
|
||||||
|
ActionAdminUserDisable = "admin_user.disable"
|
||||||
|
ActionAdminUserEnable = "admin_user.enable"
|
||||||
|
ActionAdminUserResetPassword = "admin_user.reset_password"
|
||||||
|
)
|
||||||
|
|
||||||
|
var phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`)
|
||||||
|
|
||||||
|
type AdminUserService struct {
|
||||||
|
users *repository.AdminUserRepository
|
||||||
|
audits *AuditService
|
||||||
|
defaultPlanCode string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminUserService(users *repository.AdminUserRepository, audits *AuditService, defaultPlanCode string) *AdminUserService {
|
||||||
|
defaultPlanCode = strings.TrimSpace(defaultPlanCode)
|
||||||
|
if defaultPlanCode == "" {
|
||||||
|
defaultPlanCode = "free"
|
||||||
|
}
|
||||||
|
return &AdminUserService{
|
||||||
|
users: users,
|
||||||
|
audits: audits,
|
||||||
|
defaultPlanCode: defaultPlanCode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminUserView struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Email *string `json:"email"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
TenantID *int64 `json:"tenant_id"`
|
||||||
|
TenantName *string `json:"tenant_name"`
|
||||||
|
TenantStatus *string `json:"tenant_status"`
|
||||||
|
TenantRole *string `json:"tenant_role"`
|
||||||
|
PrimaryWorkspaceID *int64 `json:"primary_workspace_id"`
|
||||||
|
PlanCode *string `json:"plan_code"`
|
||||||
|
PlanName *string `json:"plan_name"`
|
||||||
|
SubscriptionStatus *string `json:"subscription_status"`
|
||||||
|
SubscriptionEndAt *time.Time `json:"subscription_end_at"`
|
||||||
|
KolProfileID *int64 `json:"kol_profile_id"`
|
||||||
|
KolDisplayName *string `json:"kol_display_name"`
|
||||||
|
KolStatus *string `json:"kol_status"`
|
||||||
|
KolMarketEnabled *bool `json:"kol_market_enabled"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlanView struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
DurationSecs int64 `json:"duration_secs"`
|
||||||
|
ArticleLimit int `json:"article_limit"`
|
||||||
|
AIPointsMonthly int `json:"ai_points_monthly"`
|
||||||
|
BrandLimit int `json:"brand_limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoleView struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminUserListInput struct {
|
||||||
|
Keyword string
|
||||||
|
Status string
|
||||||
|
Page int
|
||||||
|
Size int
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminUserListResult struct {
|
||||||
|
Items []AdminUserView `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateAdminUserInput struct {
|
||||||
|
Phone string
|
||||||
|
Email string
|
||||||
|
Password string
|
||||||
|
Name string
|
||||||
|
PlanCode string
|
||||||
|
Role string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateAdminUserInput struct {
|
||||||
|
Phone string
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetAdminUserKOLInput struct {
|
||||||
|
Enabled bool
|
||||||
|
DisplayName string
|
||||||
|
MarketEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResetPlanUsageView struct {
|
||||||
|
TenantID int64 `json:"tenant_id"`
|
||||||
|
SubscriptionID int64 `json:"subscription_id"`
|
||||||
|
ArticleReservations int64 `json:"article_reservations"`
|
||||||
|
ArticleAmount int64 `json:"article_amount"`
|
||||||
|
AIPointReservations int64 `json:"ai_point_reservations"`
|
||||||
|
AIPointsAmount int64 `json:"ai_points_amount"`
|
||||||
|
AIPointUsageLogs int64 `json:"ai_point_usage_logs"`
|
||||||
|
ArticleWindowStart time.Time `json:"article_window_start"`
|
||||||
|
ArticleWindowEnd time.Time `json:"article_window_end"`
|
||||||
|
AIPointsWindowStart time.Time `json:"ai_points_window_start"`
|
||||||
|
AIPointsWindowEnd time.Time `json:"ai_points_window_end"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResetAdminUserPlanUsageResult struct {
|
||||||
|
User AdminUserView `json:"user"`
|
||||||
|
Reset ResetPlanUsageView `json:"reset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toAdminUserView(u *repository.AdminUserRecord) AdminUserView {
|
||||||
|
return AdminUserView{
|
||||||
|
ID: u.ID,
|
||||||
|
Email: u.Email,
|
||||||
|
Phone: u.Phone,
|
||||||
|
Name: u.Name,
|
||||||
|
Status: u.Status,
|
||||||
|
TenantID: u.TenantID,
|
||||||
|
TenantName: u.TenantName,
|
||||||
|
TenantStatus: u.TenantStatus,
|
||||||
|
TenantRole: u.TenantRole,
|
||||||
|
PrimaryWorkspaceID: u.PrimaryWorkspaceID,
|
||||||
|
PlanCode: u.PlanCode,
|
||||||
|
PlanName: u.PlanName,
|
||||||
|
SubscriptionStatus: u.SubscriptionStatus,
|
||||||
|
SubscriptionEndAt: u.SubscriptionEndAt,
|
||||||
|
KolProfileID: u.KolProfileID,
|
||||||
|
KolDisplayName: u.KolDisplayName,
|
||||||
|
KolStatus: u.KolStatus,
|
||||||
|
KolMarketEnabled: u.KolMarketEnabled,
|
||||||
|
CreatedAt: u.CreatedAt,
|
||||||
|
UpdatedAt: u.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPlanView(p repository.PlanRecord) PlanView {
|
||||||
|
return PlanView{
|
||||||
|
ID: p.ID,
|
||||||
|
Code: p.Code,
|
||||||
|
Name: p.Name,
|
||||||
|
Status: p.Status,
|
||||||
|
DurationSecs: p.DurationSecs,
|
||||||
|
ArticleLimit: p.ArticleLimit,
|
||||||
|
AIPointsMonthly: p.AIPointsMonthly,
|
||||||
|
BrandLimit: p.BrandLimit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roleViews() []RoleView {
|
||||||
|
return []RoleView{
|
||||||
|
{
|
||||||
|
Code: "tenant_admin",
|
||||||
|
Name: "管理员",
|
||||||
|
Description: "拥有当前个人空间的全部管理权限,包括成员、品牌、内容和精调模版管理。",
|
||||||
|
Permissions: sharedauth.PermissionsForRole("tenant_admin"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Code: "editor",
|
||||||
|
Name: "编辑",
|
||||||
|
Description: "可管理品牌和内容生成,但不能执行成员与高权限管理操作。",
|
||||||
|
Permissions: sharedauth.PermissionsForRole("editor"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Code: "viewer",
|
||||||
|
Name: "只读",
|
||||||
|
Description: "仅可查看工作台、品牌、文章和模版信息。",
|
||||||
|
Permissions: sharedauth.PermissionsForRole("viewer"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidTenantRole(role string) bool {
|
||||||
|
for _, item := range roleViews() {
|
||||||
|
if item.Code == role {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func workspaceRoleForTenantRole(role string) string {
|
||||||
|
switch role {
|
||||||
|
case "tenant_admin":
|
||||||
|
return "owner"
|
||||||
|
case "editor":
|
||||||
|
return "editor"
|
||||||
|
default:
|
||||||
|
return "viewer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminUserService) List(ctx context.Context, in AdminUserListInput) (*AdminUserListResult, error) {
|
||||||
|
page := in.Page
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
size := in.Size
|
||||||
|
if size <= 0 || size > 200 {
|
||||||
|
size = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
items, total, err := s.users.List(ctx, repository.AdminUserListFilter{
|
||||||
|
Keyword: strings.TrimSpace(in.Keyword),
|
||||||
|
Status: strings.TrimSpace(in.Status),
|
||||||
|
Limit: size,
|
||||||
|
Offset: (page - 1) * size,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50030, "query_failed", "failed to list users")
|
||||||
|
}
|
||||||
|
|
||||||
|
views := make([]AdminUserView, 0, len(items))
|
||||||
|
for i := range items {
|
||||||
|
views = append(views, toAdminUserView(&items[i]))
|
||||||
|
}
|
||||||
|
return &AdminUserListResult{Items: views, Total: total, Page: page, Size: size}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminUserService) ListPlans(ctx context.Context) ([]PlanView, error) {
|
||||||
|
plans, err := s.users.ListActivePlans(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50036, "query_failed", "failed to list plans")
|
||||||
|
}
|
||||||
|
views := make([]PlanView, 0, len(plans))
|
||||||
|
for _, plan := range plans {
|
||||||
|
views = append(views, toPlanView(plan))
|
||||||
|
}
|
||||||
|
return views, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminUserService) ListRoles() []RoleView {
|
||||||
|
return roleViews()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminUserService) Get(ctx context.Context, id int64) (*AdminUserView, error) {
|
||||||
|
user, err := s.users.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
||||||
|
return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
||||||
|
}
|
||||||
|
return nil, response.ErrInternal(50031, "query_failed", "failed to get user")
|
||||||
|
}
|
||||||
|
view := toAdminUserView(user)
|
||||||
|
return &view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminUserService) Create(ctx context.Context, actor *Actor, in CreateAdminUserInput) (*AdminUserView, error) {
|
||||||
|
phone, err := normalizePhone(in.Phone)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
email, err := normalizeOptionalEmail(in.Email)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(in.Password) < 8 {
|
||||||
|
return nil, response.ErrBadRequest(40031, "weak_password", "密码至少 8 位")
|
||||||
|
}
|
||||||
|
planCode := strings.TrimSpace(in.PlanCode)
|
||||||
|
if planCode == "" {
|
||||||
|
planCode = s.defaultPlanCode
|
||||||
|
}
|
||||||
|
role := strings.TrimSpace(in.Role)
|
||||||
|
if role == "" {
|
||||||
|
role = "tenant_admin"
|
||||||
|
}
|
||||||
|
if !isValidTenantRole(role) {
|
||||||
|
return nil, response.ErrBadRequest(40036, "invalid_role", "角色值无效")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := normalizeOptionalName(in.Name)
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.users.CreatePersonalTenantUser(ctx, repository.CreateAdminUserInput{
|
||||||
|
Email: email,
|
||||||
|
Phone: phone,
|
||||||
|
PasswordHash: string(hash),
|
||||||
|
Name: name,
|
||||||
|
PlanCode: planCode,
|
||||||
|
TenantRole: role,
|
||||||
|
WorkspaceRole: workspaceRoleForTenantRole(role),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, s.mapWriteError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if actor != nil {
|
||||||
|
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserCreate, "admin_user", user.ID, map[string]any{
|
||||||
|
"phone": phone,
|
||||||
|
"email": email,
|
||||||
|
"name": name,
|
||||||
|
"plan_code": planCode,
|
||||||
|
"tenant_role": role,
|
||||||
|
"tenant_id": user.TenantID,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
view := toAdminUserView(user)
|
||||||
|
return &view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminUserService) Update(ctx context.Context, actor *Actor, id int64, in UpdateAdminUserInput) error {
|
||||||
|
phone, err := normalizePhone(in.Phone)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
email, err := normalizeOptionalEmail(in.Email)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name := normalizeOptionalName(in.Name)
|
||||||
|
|
||||||
|
if err := s.users.UpdateProfile(ctx, id, repository.UpdateAdminUserProfileInput{
|
||||||
|
Email: email,
|
||||||
|
Phone: phone,
|
||||||
|
Name: name,
|
||||||
|
}); err != nil {
|
||||||
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
||||||
|
return response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
||||||
|
}
|
||||||
|
return s.mapWriteError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if actor != nil {
|
||||||
|
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserUpdateProfile, "admin_user", id, map[string]any{
|
||||||
|
"phone": phone,
|
||||||
|
"email": email,
|
||||||
|
"name": name,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminUserService) ChangePlan(ctx context.Context, actor *Actor, id int64, planCode string) (*AdminUserView, error) {
|
||||||
|
normalizedPlanCode := strings.TrimSpace(planCode)
|
||||||
|
if normalizedPlanCode == "" {
|
||||||
|
return nil, response.ErrBadRequest(40034, "invalid_plan", "请选择套餐")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.users.ChangePlan(ctx, id, normalizedPlanCode)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
||||||
|
return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
||||||
|
}
|
||||||
|
if errors.Is(err, repository.ErrDefaultPlanNotFound) {
|
||||||
|
return nil, response.ErrBadRequest(40035, "plan_not_found", "套餐不存在或已停用")
|
||||||
|
}
|
||||||
|
return nil, response.ErrInternal(50037, "update_failed", "failed to update user plan")
|
||||||
|
}
|
||||||
|
|
||||||
|
if actor != nil {
|
||||||
|
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserChangePlan, "admin_user", id, map[string]any{
|
||||||
|
"plan_code": normalizedPlanCode,
|
||||||
|
"tenant_id": user.TenantID,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
view := toAdminUserView(user)
|
||||||
|
return &view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminUserService) UpdateSubscriptionExpiry(ctx context.Context, actor *Actor, id int64, endAt time.Time) (*AdminUserView, error) {
|
||||||
|
if endAt.IsZero() {
|
||||||
|
return nil, response.ErrBadRequest(40038, "invalid_subscription_expiry", "请选择会员到期时间")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.users.UpdateSubscriptionEndAt(ctx, id, endAt.UTC())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
||||||
|
return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
||||||
|
}
|
||||||
|
if errors.Is(err, repository.ErrActivePlanNotFound) {
|
||||||
|
return nil, response.ErrBadRequest(40039, "subscription_not_found", "用户当前没有可编辑的套餐")
|
||||||
|
}
|
||||||
|
return nil, response.ErrInternal(50040, "update_failed", "failed to update subscription expiry")
|
||||||
|
}
|
||||||
|
|
||||||
|
if actor != nil {
|
||||||
|
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserChangeExpiry, "admin_user", id, map[string]any{
|
||||||
|
"tenant_id": user.TenantID,
|
||||||
|
"end_at": endAt.UTC(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
view := toAdminUserView(user)
|
||||||
|
return &view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminUserService) ResetCurrentPlanUsage(ctx context.Context, actor *Actor, id int64) (*ResetAdminUserPlanUsageResult, error) {
|
||||||
|
user, reset, err := s.users.ResetCurrentPlanUsage(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
||||||
|
return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
||||||
|
}
|
||||||
|
if errors.Is(err, repository.ErrActivePlanNotFound) {
|
||||||
|
return nil, response.ErrBadRequest(40040, "subscription_not_found", "用户当前没有可重置的套餐")
|
||||||
|
}
|
||||||
|
return nil, response.ErrInternal(50041, "reset_failed", "failed to reset current plan usage")
|
||||||
|
}
|
||||||
|
|
||||||
|
resetView := ResetPlanUsageView{
|
||||||
|
TenantID: reset.TenantID,
|
||||||
|
SubscriptionID: reset.SubscriptionID,
|
||||||
|
ArticleReservations: reset.ArticleReservations,
|
||||||
|
ArticleAmount: reset.ArticleAmount,
|
||||||
|
AIPointReservations: reset.AIPointReservations,
|
||||||
|
AIPointsAmount: reset.AIPointsAmount,
|
||||||
|
AIPointUsageLogs: reset.AIPointUsageLogs,
|
||||||
|
ArticleWindowStart: reset.ArticleWindowStart,
|
||||||
|
ArticleWindowEnd: reset.ArticleWindowEnd,
|
||||||
|
AIPointsWindowStart: reset.AIPointsWindowStart,
|
||||||
|
AIPointsWindowEnd: reset.AIPointsWindowEnd,
|
||||||
|
}
|
||||||
|
|
||||||
|
if actor != nil {
|
||||||
|
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserResetUsage, "admin_user", id, map[string]any{
|
||||||
|
"tenant_id": reset.TenantID,
|
||||||
|
"subscription_id": reset.SubscriptionID,
|
||||||
|
"article_reservations": reset.ArticleReservations,
|
||||||
|
"article_amount": reset.ArticleAmount,
|
||||||
|
"ai_point_reservations": reset.AIPointReservations,
|
||||||
|
"ai_points_amount": reset.AIPointsAmount,
|
||||||
|
"ai_point_usage_logs": reset.AIPointUsageLogs,
|
||||||
|
"article_window_start": reset.ArticleWindowStart,
|
||||||
|
"article_window_end": reset.ArticleWindowEnd,
|
||||||
|
"ai_points_window_start": reset.AIPointsWindowStart,
|
||||||
|
"ai_points_window_end": reset.AIPointsWindowEnd,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ResetAdminUserPlanUsageResult{
|
||||||
|
User: toAdminUserView(user),
|
||||||
|
Reset: resetView,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminUserService) ChangeRole(ctx context.Context, actor *Actor, id int64, role string) (*AdminUserView, error) {
|
||||||
|
normalizedRole := strings.TrimSpace(role)
|
||||||
|
if !isValidTenantRole(normalizedRole) {
|
||||||
|
return nil, response.ErrBadRequest(40036, "invalid_role", "角色值无效")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.users.ChangeRole(ctx, id, normalizedRole, workspaceRoleForTenantRole(normalizedRole))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
||||||
|
return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
||||||
|
}
|
||||||
|
return nil, response.ErrInternal(50038, "update_failed", "failed to update user role")
|
||||||
|
}
|
||||||
|
|
||||||
|
if actor != nil {
|
||||||
|
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserChangeRole, "admin_user", id, map[string]any{
|
||||||
|
"tenant_role": normalizedRole,
|
||||||
|
"tenant_id": user.TenantID,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
view := toAdminUserView(user)
|
||||||
|
return &view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminUserService) SetKOL(ctx context.Context, actor *Actor, id int64, in SetAdminUserKOLInput) (*AdminUserView, error) {
|
||||||
|
displayName := strings.TrimSpace(in.DisplayName)
|
||||||
|
if in.Enabled && len([]rune(displayName)) > 100 {
|
||||||
|
return nil, response.ErrBadRequest(40037, "invalid_kol_display_name", "KOL 昵称不能超过 100 个字")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.users.SetKOL(ctx, id, in.Enabled, displayName, in.MarketEnabled)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
||||||
|
return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
||||||
|
}
|
||||||
|
return nil, response.ErrInternal(50039, "update_failed", "failed to update KOL identity")
|
||||||
|
}
|
||||||
|
|
||||||
|
if actor != nil {
|
||||||
|
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserSetKOL, "admin_user", id, map[string]any{
|
||||||
|
"enabled": in.Enabled,
|
||||||
|
"display_name": displayName,
|
||||||
|
"market_enabled": in.MarketEnabled,
|
||||||
|
"tenant_id": user.TenantID,
|
||||||
|
"kol_profile_id": user.KolProfileID,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
view := toAdminUserView(user)
|
||||||
|
return &view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminUserService) SetStatus(ctx context.Context, actor *Actor, id int64, status string) error {
|
||||||
|
if status != domain.StatusActive && status != domain.StatusDisabled {
|
||||||
|
return response.ErrBadRequest(40032, "invalid_status", "状态值无效")
|
||||||
|
}
|
||||||
|
if err := s.users.UpdateStatus(ctx, id, status); err != nil {
|
||||||
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
||||||
|
return response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
||||||
|
}
|
||||||
|
return response.ErrInternal(50032, "update_failed", "failed to update user status")
|
||||||
|
}
|
||||||
|
|
||||||
|
action := ActionAdminUserEnable
|
||||||
|
if status == domain.StatusDisabled {
|
||||||
|
action = ActionAdminUserDisable
|
||||||
|
}
|
||||||
|
if actor != nil {
|
||||||
|
_ = s.audits.Append(ctx, actor.audit(action, "admin_user", id, nil))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminUserService) ResetPassword(ctx context.Context, actor *Actor, id int64, password string) error {
|
||||||
|
if len(password) < 8 {
|
||||||
|
return response.ErrBadRequest(40031, "weak_password", "密码至少 8 位")
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.users.UpdatePassword(ctx, id, string(hash)); err != nil {
|
||||||
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
||||||
|
return response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
||||||
|
}
|
||||||
|
return response.ErrInternal(50033, "update_failed", "failed to update user password")
|
||||||
|
}
|
||||||
|
if actor != nil {
|
||||||
|
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserResetPassword, "admin_user", id, nil))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminUserService) mapWriteError(err error) error {
|
||||||
|
if errors.Is(err, repository.ErrDefaultPlanNotFound) {
|
||||||
|
return response.ErrInternal(50034, "default_plan_missing", "默认套餐未配置")
|
||||||
|
}
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
return response.ErrConflict(40930, "duplicate_user", "手机号或邮箱已存在")
|
||||||
|
}
|
||||||
|
return response.ErrInternal(50035, "write_failed", "failed to save user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePhone(input string) (string, error) {
|
||||||
|
phone := strings.TrimSpace(input)
|
||||||
|
phone = strings.NewReplacer(" ", "", "-", "", "\t", "").Replace(phone)
|
||||||
|
if !phonePattern.MatchString(phone) {
|
||||||
|
return "", response.ErrBadRequest(40030, "invalid_phone", "请输入有效手机号")
|
||||||
|
}
|
||||||
|
return phone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeOptionalEmail(input string) (*string, error) {
|
||||||
|
email := strings.TrimSpace(input)
|
||||||
|
if email == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
parsed, err := mail.ParseAddress(email)
|
||||||
|
if err != nil || parsed.Address != email {
|
||||||
|
return nil, response.ErrBadRequest(40033, "invalid_email", "邮箱格式无效")
|
||||||
|
}
|
||||||
|
return &email, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeOptionalName(input string) *string {
|
||||||
|
name := strings.TrimSpace(input)
|
||||||
|
if name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &name
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ type Config struct {
|
|||||||
Log sharedconfig.LogConfig `mapstructure:"log"`
|
Log sharedconfig.LogConfig `mapstructure:"log"`
|
||||||
JWT JWTConfig `mapstructure:"jwt"`
|
JWT JWTConfig `mapstructure:"jwt"`
|
||||||
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
|
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
|
||||||
|
AdminUsers AdminUsersConfig `mapstructure:"admin_users"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type JWTConfig struct {
|
type JWTConfig struct {
|
||||||
@@ -31,6 +32,10 @@ type DefaultAdminConfig struct {
|
|||||||
Email string `mapstructure:"email"`
|
Email string `mapstructure:"email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AdminUsersConfig struct {
|
||||||
|
DefaultPlanCode string `mapstructure:"default_plan_code"`
|
||||||
|
}
|
||||||
|
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
v := viper.New()
|
v := viper.New()
|
||||||
v.SetConfigFile(path)
|
v.SetConfigFile(path)
|
||||||
@@ -46,6 +51,7 @@ func Load(path string) (*Config, error) {
|
|||||||
v.SetDefault("jwt.access_ttl", 8*time.Hour)
|
v.SetDefault("jwt.access_ttl", 8*time.Hour)
|
||||||
v.SetDefault("default_admin.username", "admin")
|
v.SetDefault("default_admin.username", "admin")
|
||||||
v.SetDefault("default_admin.display_name", "Administrator")
|
v.SetDefault("default_admin.display_name", "Administrator")
|
||||||
|
v.SetDefault("admin_users.default_plan_code", "free")
|
||||||
|
|
||||||
if err := v.ReadInConfig(); err != nil {
|
if err := v.ReadInConfig(); err != nil {
|
||||||
return nil, fmt.Errorf("read config: %w", err)
|
return nil, fmt.Errorf("read config: %w", err)
|
||||||
@@ -83,5 +89,8 @@ func applyEnvOverrides(cfg *Config) error {
|
|||||||
if v := os.Getenv("OPS_DEFAULT_ADMIN_EMAIL"); v != "" {
|
if v := os.Getenv("OPS_DEFAULT_ADMIN_EMAIL"); v != "" {
|
||||||
cfg.DefaultAdmin.Email = v
|
cfg.DefaultAdmin.Email = v
|
||||||
}
|
}
|
||||||
|
if v := os.Getenv("OPS_ADMIN_USERS_DEFAULT_PLAN_CODE"); v != "" {
|
||||||
|
cfg.AdminUsers.DefaultPlanCode = v
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,833 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
tenantrepo "github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAdminUserNotFound = errors.New("admin user not found")
|
||||||
|
ErrDefaultPlanNotFound = errors.New("default plan not found")
|
||||||
|
ErrActivePlanNotFound = errors.New("active plan subscription not found")
|
||||||
|
ErrPrimaryWorkspaceEmpty = errors.New("primary workspace not created")
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminUserRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminUserRepository(pool *pgxpool.Pool) *AdminUserRepository {
|
||||||
|
return &AdminUserRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminUserRecord struct {
|
||||||
|
ID int64
|
||||||
|
Email *string
|
||||||
|
Phone string
|
||||||
|
Name *string
|
||||||
|
Status string
|
||||||
|
TenantID *int64
|
||||||
|
TenantName *string
|
||||||
|
TenantStatus *string
|
||||||
|
TenantRole *string
|
||||||
|
PrimaryWorkspaceID *int64
|
||||||
|
PlanCode *string
|
||||||
|
PlanName *string
|
||||||
|
SubscriptionStatus *string
|
||||||
|
SubscriptionEndAt *time.Time
|
||||||
|
KolProfileID *int64
|
||||||
|
KolDisplayName *string
|
||||||
|
KolStatus *string
|
||||||
|
KolMarketEnabled *bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlanRecord struct {
|
||||||
|
ID int64
|
||||||
|
Code string
|
||||||
|
Name string
|
||||||
|
Status string
|
||||||
|
DurationSecs int64
|
||||||
|
ArticleLimit int
|
||||||
|
AIPointsMonthly int
|
||||||
|
BrandLimit int
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminUserListFilter struct {
|
||||||
|
Keyword string
|
||||||
|
Status string
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateAdminUserInput struct {
|
||||||
|
Email *string
|
||||||
|
Phone string
|
||||||
|
PasswordHash string
|
||||||
|
Name *string
|
||||||
|
PlanCode string
|
||||||
|
TenantRole string
|
||||||
|
WorkspaceRole string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateAdminUserProfileInput struct {
|
||||||
|
Email *string
|
||||||
|
Phone string
|
||||||
|
Name *string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResetPlanUsageResult struct {
|
||||||
|
TenantID int64
|
||||||
|
SubscriptionID int64
|
||||||
|
ArticleReservations int64
|
||||||
|
ArticleAmount int64
|
||||||
|
AIPointReservations int64
|
||||||
|
AIPointsAmount int64
|
||||||
|
AIPointUsageLogs int64
|
||||||
|
ArticleWindowStart time.Time
|
||||||
|
ArticleWindowEnd time.Time
|
||||||
|
AIPointsWindowStart time.Time
|
||||||
|
AIPointsWindowEnd time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminUserColumns = `
|
||||||
|
u.id,
|
||||||
|
u.email,
|
||||||
|
u.phone,
|
||||||
|
u.name,
|
||||||
|
u.status,
|
||||||
|
tm.tenant_id,
|
||||||
|
t.name AS tenant_name,
|
||||||
|
t.status AS tenant_status,
|
||||||
|
tm.tenant_role,
|
||||||
|
wm.workspace_id AS primary_workspace_id,
|
||||||
|
sub.plan_code,
|
||||||
|
sub.plan_name,
|
||||||
|
sub.subscription_status,
|
||||||
|
sub.subscription_end_at,
|
||||||
|
kp.id AS kol_profile_id,
|
||||||
|
kp.display_name AS kol_display_name,
|
||||||
|
kp.status AS kol_status,
|
||||||
|
kp.market_enabled AS kol_market_enabled,
|
||||||
|
u.created_at,
|
||||||
|
u.updated_at`
|
||||||
|
|
||||||
|
const adminUserFromClause = `
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT tm.tenant_id, tm.tenant_role
|
||||||
|
FROM tenant_memberships tm
|
||||||
|
WHERE tm.user_id = u.id
|
||||||
|
AND tm.deleted_at IS NULL
|
||||||
|
ORDER BY tm.created_at ASC, tm.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
) tm ON TRUE
|
||||||
|
LEFT JOIN tenants t
|
||||||
|
ON t.id = tm.tenant_id
|
||||||
|
AND t.deleted_at IS NULL
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT wm.workspace_id
|
||||||
|
FROM workspace_memberships wm
|
||||||
|
WHERE wm.user_id = u.id
|
||||||
|
AND wm.tenant_id = tm.tenant_id
|
||||||
|
AND wm.is_primary = TRUE
|
||||||
|
ORDER BY wm.created_at ASC, wm.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
) wm ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
p.plan_code,
|
||||||
|
p.name AS plan_name,
|
||||||
|
s.status AS subscription_status,
|
||||||
|
s.end_at AS subscription_end_at
|
||||||
|
FROM tenant_plan_subscriptions s
|
||||||
|
JOIN plans p
|
||||||
|
ON p.id = s.plan_id
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
WHERE s.tenant_id = tm.tenant_id
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN s.status = 'active'
|
||||||
|
AND p.status = 'active'
|
||||||
|
AND s.start_at <= NOW()
|
||||||
|
AND s.end_at > NOW()
|
||||||
|
THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END,
|
||||||
|
s.start_at DESC,
|
||||||
|
s.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) sub ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT kp.id, kp.display_name, kp.status, kp.market_enabled
|
||||||
|
FROM kol_profiles kp
|
||||||
|
WHERE kp.user_id = u.id
|
||||||
|
AND kp.deleted_at IS NULL
|
||||||
|
ORDER BY kp.created_at ASC, kp.id ASC
|
||||||
|
LIMIT 1
|
||||||
|
) kp ON TRUE`
|
||||||
|
|
||||||
|
func scanAdminUser(row pgx.Row) (*AdminUserRecord, error) {
|
||||||
|
var u AdminUserRecord
|
||||||
|
err := row.Scan(
|
||||||
|
&u.ID,
|
||||||
|
&u.Email,
|
||||||
|
&u.Phone,
|
||||||
|
&u.Name,
|
||||||
|
&u.Status,
|
||||||
|
&u.TenantID,
|
||||||
|
&u.TenantName,
|
||||||
|
&u.TenantStatus,
|
||||||
|
&u.TenantRole,
|
||||||
|
&u.PrimaryWorkspaceID,
|
||||||
|
&u.PlanCode,
|
||||||
|
&u.PlanName,
|
||||||
|
&u.SubscriptionStatus,
|
||||||
|
&u.SubscriptionEndAt,
|
||||||
|
&u.KolProfileID,
|
||||||
|
&u.KolDisplayName,
|
||||||
|
&u.KolStatus,
|
||||||
|
&u.KolMarketEnabled,
|
||||||
|
&u.CreatedAt,
|
||||||
|
&u.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrAdminUserNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AdminUserRepository) GetByID(ctx context.Context, id int64) (*AdminUserRecord, error) {
|
||||||
|
row := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT `+adminUserColumns+`
|
||||||
|
`+adminUserFromClause+`
|
||||||
|
WHERE u.id = $1
|
||||||
|
AND u.deleted_at IS NULL`, id)
|
||||||
|
return scanAdminUser(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AdminUserRepository) List(ctx context.Context, f AdminUserListFilter) ([]AdminUserRecord, int64, error) {
|
||||||
|
args := []any{}
|
||||||
|
where := "u.deleted_at IS NULL"
|
||||||
|
|
||||||
|
if f.Keyword != "" {
|
||||||
|
args = append(args, "%"+f.Keyword+"%")
|
||||||
|
where += fmt.Sprintf(" AND (u.name ILIKE $%d OR u.email ILIKE $%d OR u.phone ILIKE $%d OR t.name ILIKE $%d)",
|
||||||
|
len(args), len(args), len(args), len(args))
|
||||||
|
}
|
||||||
|
if f.Status != "" {
|
||||||
|
args = append(args, f.Status)
|
||||||
|
where += fmt.Sprintf(" AND u.status = $%d", len(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*)
|
||||||
|
`+adminUserFromClause+`
|
||||||
|
WHERE `+where, args...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := f.Limit
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
offset := f.Offset
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
limitArg := len(args) - 1
|
||||||
|
offsetArg := len(args)
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx, fmt.Sprintf(`
|
||||||
|
SELECT %s
|
||||||
|
%s
|
||||||
|
WHERE %s
|
||||||
|
ORDER BY u.created_at DESC, u.id DESC
|
||||||
|
LIMIT $%d OFFSET $%d`, adminUserColumns, adminUserFromClause, where, limitArg, offsetArg), args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]AdminUserRecord, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
record, err := scanAdminUser(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out = append(out, *record)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AdminUserRepository) ListActivePlans(ctx context.Context) ([]PlanRecord, error) {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
plan_code,
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
COALESCE(NULLIF(quota_policy_json->>'subscription_duration_seconds', '')::BIGINT, 259200),
|
||||||
|
COALESCE(NULLIF(quota_policy_json->>'article_generation', '')::INT, 0),
|
||||||
|
COALESCE(NULLIF(quota_policy_json->>'ai_points_monthly', '')::INT, 0),
|
||||||
|
COALESCE(NULLIF(quota_policy_json->>'brand_limit', '')::INT, 0)
|
||||||
|
FROM plans
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
ORDER BY id ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []PlanRecord{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p PlanRecord
|
||||||
|
if err := rows.Scan(
|
||||||
|
&p.ID,
|
||||||
|
&p.Code,
|
||||||
|
&p.Name,
|
||||||
|
&p.Status,
|
||||||
|
&p.DurationSecs,
|
||||||
|
&p.ArticleLimit,
|
||||||
|
&p.AIPointsMonthly,
|
||||||
|
&p.BrandLimit,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AdminUserRepository) CreatePersonalTenantUser(ctx context.Context, in CreateAdminUserInput) (*AdminUserRecord, error) {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = tx.Rollback(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var userID int64
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO users (email, phone, password_hash, name, status)
|
||||||
|
VALUES ($1, $2, $3, $4, 'active')
|
||||||
|
RETURNING id`, in.Email, in.Phone, in.PasswordHash, in.Name).Scan(&userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var tenantID int64
|
||||||
|
tenantName := fmt.Sprintf("user-%d", userID)
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO tenants (name, status)
|
||||||
|
VALUES ($1, 'active')
|
||||||
|
RETURNING id`, tenantName).Scan(&tenantID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO tenant_memberships (tenant_id, user_id, tenant_role)
|
||||||
|
VALUES ($1, $2, $3)`, tenantID, userID, in.TenantRole); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var workspaceID int64
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO workspaces (tenant_id, name, slug, is_default)
|
||||||
|
VALUES ($1, 'Default', 'default', TRUE)
|
||||||
|
RETURNING id`, tenantID).Scan(&workspaceID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if workspaceID == 0 {
|
||||||
|
return nil, ErrPrimaryWorkspaceEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO workspace_memberships (workspace_id, user_id, tenant_id, role, is_primary)
|
||||||
|
VALUES ($1, $2, $3, $4, TRUE)`, workspaceID, userID, tenantID, in.WorkspaceRole); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
planID int64
|
||||||
|
durationSeconds int64
|
||||||
|
)
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT id,
|
||||||
|
COALESCE(NULLIF(quota_policy_json->>'subscription_duration_seconds', '')::BIGINT, 259200)
|
||||||
|
FROM plans
|
||||||
|
WHERE plan_code = $1
|
||||||
|
AND status = 'active'
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
LIMIT 1`, in.PlanCode).Scan(&planID, &durationSeconds); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrDefaultPlanNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if durationSeconds <= 0 {
|
||||||
|
durationSeconds = 259200
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO tenant_plan_subscriptions (tenant_id, plan_id, start_at, end_at, status)
|
||||||
|
VALUES ($1, $2, NOW(), NOW() + ($3::BIGINT * INTERVAL '1 second'), 'active')`,
|
||||||
|
tenantID, planID, durationSeconds); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.GetByID(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AdminUserRepository) ChangePlan(ctx context.Context, userID int64, planCode string) (*AdminUserRecord, error) {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = tx.Rollback(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var tenantID int64
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT tm.tenant_id
|
||||||
|
FROM tenant_memberships tm
|
||||||
|
WHERE tm.user_id = $1
|
||||||
|
AND tm.deleted_at IS NULL
|
||||||
|
ORDER BY tm.created_at ASC, tm.id ASC
|
||||||
|
LIMIT 1`, userID).Scan(&tenantID); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrAdminUserNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
planID int64
|
||||||
|
durationSeconds int64
|
||||||
|
)
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT id,
|
||||||
|
COALESCE(NULLIF(quota_policy_json->>'subscription_duration_seconds', '')::BIGINT, 259200)
|
||||||
|
FROM plans
|
||||||
|
WHERE plan_code = $1
|
||||||
|
AND status = 'active'
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
LIMIT 1`, planCode).Scan(&planID, &durationSeconds); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrDefaultPlanNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if durationSeconds <= 0 {
|
||||||
|
durationSeconds = 259200
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE tenant_plan_subscriptions
|
||||||
|
SET status = 'replaced',
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND status = 'active'
|
||||||
|
AND deleted_at IS NULL`, tenantID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO tenant_plan_subscriptions (tenant_id, plan_id, start_at, end_at, status)
|
||||||
|
VALUES ($1, $2, NOW(), NOW() + ($3::BIGINT * INTERVAL '1 second'), 'active')`,
|
||||||
|
tenantID, planID, durationSeconds); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.GetByID(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AdminUserRepository) UpdateSubscriptionEndAt(ctx context.Context, userID int64, endAt time.Time) (*AdminUserRecord, error) {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = tx.Rollback(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var tenantID int64
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT tm.tenant_id
|
||||||
|
FROM tenant_memberships tm
|
||||||
|
WHERE tm.user_id = $1
|
||||||
|
AND tm.deleted_at IS NULL
|
||||||
|
ORDER BY tm.created_at ASC, tm.id ASC
|
||||||
|
LIMIT 1`, userID).Scan(&tenantID); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrAdminUserNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := tx.Exec(ctx, `
|
||||||
|
UPDATE tenant_plan_subscriptions
|
||||||
|
SET end_at = $1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE tenant_id = $2
|
||||||
|
AND status = 'active'
|
||||||
|
AND deleted_at IS NULL`, endAt.UTC(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return nil, ErrActivePlanNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.GetByID(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AdminUserRepository) ResetCurrentPlanUsage(ctx context.Context, userID int64) (*AdminUserRecord, ResetPlanUsageResult, error) {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ResetPlanUsageResult{}, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = tx.Rollback(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var tenantID int64
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT tm.tenant_id
|
||||||
|
FROM tenant_memberships tm
|
||||||
|
WHERE tm.user_id = $1
|
||||||
|
AND tm.deleted_at IS NULL
|
||||||
|
ORDER BY tm.created_at ASC, tm.id ASC
|
||||||
|
LIMIT 1`, userID).Scan(&tenantID); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ResetPlanUsageResult{}, ErrAdminUserNotFound
|
||||||
|
}
|
||||||
|
return nil, ResetPlanUsageResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(20260428, hashint8($1)::int)`, tenantID); err != nil {
|
||||||
|
return nil, ResetPlanUsageResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
access, err := tenantrepo.NewTenantPlanRepository(tx).GetTenantPlanAccess(ctx, tenantID, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ResetPlanUsageResult{}, err
|
||||||
|
}
|
||||||
|
if access == nil || access.SubscriptionStatus != "active" {
|
||||||
|
return nil, ResetPlanUsageResult{}, ErrActivePlanNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
referenceTime := now
|
||||||
|
if !access.EndAt.IsZero() && !access.EndAt.After(now) && access.EndAt.After(access.StartAt) {
|
||||||
|
referenceTime = access.EndAt.Add(-time.Nanosecond)
|
||||||
|
}
|
||||||
|
if referenceTime.Before(access.StartAt) {
|
||||||
|
referenceTime = access.StartAt
|
||||||
|
}
|
||||||
|
|
||||||
|
articleStart, articleEnd, _ := access.ArticleQuotaWindow(referenceTime)
|
||||||
|
aiStart, aiEnd, _ := access.AIPointsWindow(referenceTime)
|
||||||
|
|
||||||
|
result := ResetPlanUsageResult{
|
||||||
|
TenantID: tenantID,
|
||||||
|
SubscriptionID: access.SubscriptionID,
|
||||||
|
ArticleWindowStart: articleStart,
|
||||||
|
ArticleWindowEnd: articleEnd,
|
||||||
|
AIPointsWindowStart: aiStart,
|
||||||
|
AIPointsWindowEnd: aiEnd,
|
||||||
|
}
|
||||||
|
|
||||||
|
if articleEnd.After(articleStart) {
|
||||||
|
count, amount, err := resetQuotaReservations(ctx, tx, tenantID, "article_generation", articleStart, articleEnd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ResetPlanUsageResult{}, err
|
||||||
|
}
|
||||||
|
result.ArticleReservations = count
|
||||||
|
result.ArticleAmount = amount
|
||||||
|
}
|
||||||
|
|
||||||
|
if aiEnd.After(aiStart) {
|
||||||
|
count, amount, logCount, err := resetAIPointReservations(ctx, tx, tenantID, aiStart, aiEnd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ResetPlanUsageResult{}, err
|
||||||
|
}
|
||||||
|
result.AIPointReservations = count
|
||||||
|
result.AIPointsAmount = amount
|
||||||
|
result.AIPointUsageLogs = logCount
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, ResetPlanUsageResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := r.GetByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ResetPlanUsageResult{}, err
|
||||||
|
}
|
||||||
|
return user, result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetQuotaReservations(ctx context.Context, tx pgx.Tx, tenantID int64, quotaType string, startAt, endAt time.Time) (int64, int64, error) {
|
||||||
|
row := tx.QueryRow(ctx, `
|
||||||
|
WITH reset AS (
|
||||||
|
UPDATE quota_reservations
|
||||||
|
SET status = 'refunded',
|
||||||
|
refunded_amount = reserved_amount,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND quota_type = $2
|
||||||
|
AND status IN ('pending', 'confirmed')
|
||||||
|
AND created_at >= $3
|
||||||
|
AND created_at < $4
|
||||||
|
RETURNING reserved_amount
|
||||||
|
)
|
||||||
|
SELECT COUNT(*)::BIGINT,
|
||||||
|
COALESCE(SUM(reserved_amount), 0)::BIGINT
|
||||||
|
FROM reset`, tenantID, quotaType, startAt.UTC(), endAt.UTC())
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
var amount int64
|
||||||
|
if err := row.Scan(&count, &amount); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return count, amount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetAIPointReservations(ctx context.Context, tx pgx.Tx, tenantID int64, startAt, endAt time.Time) (int64, int64, int64, error) {
|
||||||
|
row := tx.QueryRow(ctx, `
|
||||||
|
WITH reset AS (
|
||||||
|
UPDATE quota_reservations
|
||||||
|
SET status = 'refunded',
|
||||||
|
refunded_amount = reserved_amount,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND quota_type = 'ai_points'
|
||||||
|
AND status IN ('pending', 'confirmed')
|
||||||
|
AND created_at >= $2
|
||||||
|
AND created_at < $3
|
||||||
|
RETURNING id, reserved_amount
|
||||||
|
),
|
||||||
|
logs AS (
|
||||||
|
UPDATE ai_point_usage_logs l
|
||||||
|
SET status = 'refunded',
|
||||||
|
error_message = 'ops reset current plan usage',
|
||||||
|
completed_at = COALESCE(l.completed_at, NOW()),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM reset r
|
||||||
|
WHERE l.tenant_id = $1
|
||||||
|
AND l.quota_reservation_id = r.id
|
||||||
|
AND l.status IN ('pending', 'completed')
|
||||||
|
RETURNING l.id
|
||||||
|
)
|
||||||
|
SELECT (SELECT COUNT(*) FROM reset)::BIGINT,
|
||||||
|
COALESCE((SELECT SUM(reserved_amount) FROM reset), 0)::BIGINT,
|
||||||
|
(SELECT COUNT(*) FROM logs)::BIGINT`, tenantID, startAt.UTC(), endAt.UTC())
|
||||||
|
|
||||||
|
var reservationCount int64
|
||||||
|
var amount int64
|
||||||
|
var logCount int64
|
||||||
|
if err := row.Scan(&reservationCount, &amount, &logCount); err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
return reservationCount, amount, logCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AdminUserRepository) ChangeRole(ctx context.Context, userID int64, tenantRole, workspaceRole string) (*AdminUserRecord, error) {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = tx.Rollback(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var tenantID int64
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT tm.tenant_id
|
||||||
|
FROM tenant_memberships tm
|
||||||
|
WHERE tm.user_id = $1
|
||||||
|
AND tm.deleted_at IS NULL
|
||||||
|
ORDER BY tm.created_at ASC, tm.id ASC
|
||||||
|
LIMIT 1`, userID).Scan(&tenantID); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrAdminUserNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := tx.Exec(ctx, `
|
||||||
|
UPDATE tenant_memberships
|
||||||
|
SET tenant_role = $1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE tenant_id = $2
|
||||||
|
AND user_id = $3
|
||||||
|
AND deleted_at IS NULL`, tenantRole, tenantID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return nil, ErrAdminUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE workspace_memberships
|
||||||
|
SET role = $1
|
||||||
|
WHERE tenant_id = $2
|
||||||
|
AND user_id = $3`, workspaceRole, tenantID, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.GetByID(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AdminUserRepository) SetKOL(ctx context.Context, userID int64, enabled bool, displayName string, marketEnabled bool) (*AdminUserRecord, error) {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = tx.Rollback(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var (
|
||||||
|
tenantID int64
|
||||||
|
userName *string
|
||||||
|
phone string
|
||||||
|
)
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT tm.tenant_id, u.name, u.phone
|
||||||
|
FROM users u
|
||||||
|
JOIN tenant_memberships tm
|
||||||
|
ON tm.user_id = u.id
|
||||||
|
AND tm.deleted_at IS NULL
|
||||||
|
WHERE u.id = $1
|
||||||
|
AND u.deleted_at IS NULL
|
||||||
|
ORDER BY tm.created_at ASC, tm.id ASC
|
||||||
|
LIMIT 1`, userID).Scan(&tenantID, &userName, &phone); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrAdminUserNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if displayName == "" {
|
||||||
|
if userName != nil && *userName != "" {
|
||||||
|
displayName = *userName
|
||||||
|
} else {
|
||||||
|
displayName = phone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO kol_profiles (tenant_id, user_id, display_name, market_enabled, status)
|
||||||
|
VALUES ($1, $2, $3, $4, 'active')
|
||||||
|
ON CONFLICT (user_id) WHERE deleted_at IS NULL
|
||||||
|
DO UPDATE SET
|
||||||
|
display_name = EXCLUDED.display_name,
|
||||||
|
market_enabled = EXCLUDED.market_enabled,
|
||||||
|
status = 'active',
|
||||||
|
updated_at = NOW()`, tenantID, userID, displayName, marketEnabled); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE kol_profiles
|
||||||
|
SET status = 'suspended',
|
||||||
|
market_enabled = FALSE,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND deleted_at IS NULL`, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.GetByID(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AdminUserRepository) UpdateProfile(ctx context.Context, id int64, in UpdateAdminUserProfileInput) error {
|
||||||
|
cmd, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE users
|
||||||
|
SET email = $1,
|
||||||
|
phone = $2,
|
||||||
|
name = $3,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $4
|
||||||
|
AND deleted_at IS NULL`, in.Email, in.Phone, in.Name, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cmd.RowsAffected() == 0 {
|
||||||
|
return ErrAdminUserNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AdminUserRepository) UpdateStatus(ctx context.Context, id int64, status string) error {
|
||||||
|
cmd, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE users
|
||||||
|
SET status = $1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
AND deleted_at IS NULL`, status, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cmd.RowsAffected() == 0 {
|
||||||
|
return ErrAdminUserNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AdminUserRepository) UpdatePassword(ctx context.Context, id int64, passwordHash string) error {
|
||||||
|
cmd, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = $1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
AND deleted_at IS NULL`, passwordHash, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cmd.RowsAffected() == 0 {
|
||||||
|
return ErrAdminUserNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/ops/app"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
type createAdminUserRequest struct {
|
||||||
|
Phone string `json:"phone" binding:"required"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
PlanCode string `json:"plan_code"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateAdminUserRequest struct {
|
||||||
|
Phone string `json:"phone" binding:"required"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type changeAdminUserPlanRequest struct {
|
||||||
|
PlanCode string `json:"plan_code" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateAdminUserSubscriptionExpiryRequest struct {
|
||||||
|
EndAt time.Time `json:"end_at" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type changeAdminUserRoleRequest struct {
|
||||||
|
Role string `json:"role" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type setAdminUserKOLRequest struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
MarketEnabled bool `json:"market_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func listPlansHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
plans, err := svc.ListPlans(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, plans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listRolesHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
response.Success(c, svc.ListRoles())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listAdminUsersHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||||
|
result, err := svc.List(c.Request.Context(), app.AdminUserListInput{
|
||||||
|
Keyword: c.Query("keyword"),
|
||||||
|
Status: c.Query("status"),
|
||||||
|
Page: page,
|
||||||
|
Size: size,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAdminUserHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body createAdminUserRequest
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail, err := svc.Create(c.Request.Context(), actorFromGin(c), app.CreateAdminUserInput{
|
||||||
|
Phone: body.Phone,
|
||||||
|
Email: body.Email,
|
||||||
|
Name: body.Name,
|
||||||
|
Password: body.Password,
|
||||||
|
PlanCode: body.PlanCode,
|
||||||
|
Role: body.Role,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAdminUserHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id, err := parseIDParam(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail, err := svc.Get(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAdminUserHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id, err := parseIDParam(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body updateAdminUserRequest
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svc.Update(c.Request.Context(), actorFromGin(c), id, app.UpdateAdminUserInput{
|
||||||
|
Phone: body.Phone,
|
||||||
|
Email: body.Email,
|
||||||
|
Name: body.Name,
|
||||||
|
}); err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, gin.H{"id": id})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func changeAdminUserPlanHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id, err := parseIDParam(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body changeAdminUserPlanRequest
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail, err := svc.ChangePlan(c.Request.Context(), actorFromGin(c), id, body.PlanCode)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAdminUserSubscriptionExpiryHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id, err := parseIDParam(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body updateAdminUserSubscriptionExpiryRequest
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail, err := svc.UpdateSubscriptionExpiry(c.Request.Context(), actorFromGin(c), id, body.EndAt)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetAdminUserPlanUsageHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id, err := parseIDParam(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := svc.ResetCurrentPlanUsage(c.Request.Context(), actorFromGin(c), id)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func changeAdminUserRoleHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id, err := parseIDParam(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body changeAdminUserRoleRequest
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail, err := svc.ChangeRole(c.Request.Context(), actorFromGin(c), id, body.Role)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAdminUserKOLHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id, err := parseIDParam(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body setAdminUserKOLRequest
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail, err := svc.SetKOL(c.Request.Context(), actorFromGin(c), id, app.SetAdminUserKOLInput{
|
||||||
|
Enabled: body.Enabled,
|
||||||
|
DisplayName: body.DisplayName,
|
||||||
|
MarketEnabled: body.MarketEnabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAdminUserStatusHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id, err := parseIDParam(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body setStatusRequest
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svc.SetStatus(c.Request.Context(), actorFromGin(c), id, body.Status); err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, gin.H{"id": id, "status": body.Status})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetAdminUserPasswordHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id, err := parseIDParam(c)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body resetPasswordRequest
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svc.ResetPassword(c.Request.Context(), actorFromGin(c), id, body.Password); err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, gin.H{"id": id})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,13 +11,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Deps struct {
|
type Deps struct {
|
||||||
Engine *gin.Engine
|
Engine *gin.Engine
|
||||||
Logger *zap.Logger
|
Logger *zap.Logger
|
||||||
DB *pgxpool.Pool
|
DB *pgxpool.Pool
|
||||||
Issuer *app.TokenIssuer
|
Issuer *app.TokenIssuer
|
||||||
Auth *app.AuthService
|
Auth *app.AuthService
|
||||||
Accounts *app.AccountService
|
Accounts *app.AccountService
|
||||||
Audits *app.AuditService
|
AdminUsers *app.AdminUserService
|
||||||
|
Audits *app.AuditService
|
||||||
}
|
}
|
||||||
|
|
||||||
func RegisterRoutes(d Deps) {
|
func RegisterRoutes(d Deps) {
|
||||||
@@ -57,6 +58,21 @@ func RegisterRoutes(d Deps) {
|
|||||||
authed.POST("/accounts/:id/status", setAccountStatusHandler(d.Accounts))
|
authed.POST("/accounts/:id/status", setAccountStatusHandler(d.Accounts))
|
||||||
authed.POST("/accounts/:id/password", resetAccountPasswordHandler(d.Accounts))
|
authed.POST("/accounts/:id/password", resetAccountPasswordHandler(d.Accounts))
|
||||||
|
|
||||||
|
authed.GET("/plans", listPlansHandler(d.AdminUsers))
|
||||||
|
authed.GET("/roles", listRolesHandler(d.AdminUsers))
|
||||||
|
|
||||||
|
authed.GET("/admin-users", listAdminUsersHandler(d.AdminUsers))
|
||||||
|
authed.POST("/admin-users", createAdminUserHandler(d.AdminUsers))
|
||||||
|
authed.GET("/admin-users/:id", getAdminUserHandler(d.AdminUsers))
|
||||||
|
authed.PATCH("/admin-users/:id", updateAdminUserHandler(d.AdminUsers))
|
||||||
|
authed.POST("/admin-users/:id/plan", changeAdminUserPlanHandler(d.AdminUsers))
|
||||||
|
authed.POST("/admin-users/:id/subscription-expiry", updateAdminUserSubscriptionExpiryHandler(d.AdminUsers))
|
||||||
|
authed.POST("/admin-users/:id/reset-plan-usage", resetAdminUserPlanUsageHandler(d.AdminUsers))
|
||||||
|
authed.POST("/admin-users/:id/role", changeAdminUserRoleHandler(d.AdminUsers))
|
||||||
|
authed.POST("/admin-users/:id/kol", setAdminUserKOLHandler(d.AdminUsers))
|
||||||
|
authed.POST("/admin-users/:id/status", setAdminUserStatusHandler(d.AdminUsers))
|
||||||
|
authed.POST("/admin-users/:id/password", resetAdminUserPasswordHandler(d.AdminUsers))
|
||||||
|
|
||||||
authed.GET("/audits", listAuditsHandler(d.Audits))
|
authed.GET("/audits", listAuditsHandler(d.Audits))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user