feat: establish GoAuto MVP baseline (#19)
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
## 背景
|
||||
|
||||
## 目标
|
||||
|
||||
- (填写)
|
||||
|
||||
## 非目标
|
||||
|
||||
- (填写)
|
||||
|
||||
## 总体方案
|
||||
|
||||
## 阶段路线
|
||||
|
||||
1. (填写)
|
||||
|
||||
## MVP 与任务索引
|
||||
|
||||
- [ ] # MVP 工单
|
||||
|
||||
## 依赖、风险和回退
|
||||
|
||||
## 最终验收标准
|
||||
|
||||
- [ ] (填写)
|
||||
@@ -0,0 +1,23 @@
|
||||
## 基本信息
|
||||
|
||||
- 所属 Epic:#
|
||||
|
||||
## MVP 目标
|
||||
|
||||
## 包含范围
|
||||
|
||||
- (填写)
|
||||
|
||||
## 排除范围
|
||||
|
||||
- (填写)
|
||||
|
||||
## 阶段与单元任务
|
||||
|
||||
- [ ] # 单元任务
|
||||
|
||||
## 集成风险和回退
|
||||
|
||||
## MVP 验收标准
|
||||
|
||||
- [ ] (填写)
|
||||
@@ -0,0 +1,61 @@
|
||||
## 基本信息
|
||||
|
||||
- 类型:需求 / 缺陷 / 重构
|
||||
- 所属 Epic:#
|
||||
- 所属 MVP / 版本:#
|
||||
- 阶段:待实施
|
||||
|
||||
## 依赖与并行
|
||||
|
||||
- 前置工单:无 / #编号
|
||||
- 是否允许与前置工单并行:是 / 否
|
||||
- 原因:
|
||||
|
||||
## 子项目影响
|
||||
|
||||
- 仅影响的子项目 / 交付单元:server / web / android / shared-docs
|
||||
- 是否跨子项目:是 / 否
|
||||
- 是否修改共享接口或契约:是 / 否;唯一事实来源:`docs/08-agent-api-contract.md`
|
||||
- 各子项目需要执行的验证:
|
||||
|
||||
## 原始需求
|
||||
|
||||
- 来源:用户对话 / Gitea / 其他
|
||||
- 提出时间:
|
||||
- 关键原话或脱敏摘要:
|
||||
|
||||
## 要解决什么
|
||||
|
||||
## 做什么 / 不做什么
|
||||
|
||||
- 做:
|
||||
- 不做:
|
||||
|
||||
## 已确认方案
|
||||
|
||||
预计修改文件:
|
||||
|
||||
- (填写)
|
||||
|
||||
## 需求变化记录
|
||||
|
||||
| 日期 | 变化内容 | 原因 | 用户确认 |
|
||||
|---|---|---|---|
|
||||
| | 无 | | 是 |
|
||||
|
||||
## 文档影响
|
||||
|
||||
- [ ] 不影响长期文档,原因:
|
||||
- [ ] 更新项目档案或本地开发与验证
|
||||
- [ ] 更新架构与代码地图
|
||||
- [ ] 更新业务规则与术语
|
||||
- [ ] 更新 API 契约
|
||||
- [ ] 更新常见修改或故障排查
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] (填写)
|
||||
|
||||
## 验证方式
|
||||
|
||||
## 风险和回退
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/go-mobile-control-demo.exe
|
||||
/portal-poc.exe
|
||||
/portal-poc-new.exe
|
||||
/portal-poc*.log
|
||||
/.cache/
|
||||
/.playwright-mcp/
|
||||
/server*.log
|
||||
/android-sound-helper/.gradle/
|
||||
/android-sound-helper/build/
|
||||
/android-sound-helper/app/build/
|
||||
|
||||
# Archived proof-of-concept code and local artifacts.
|
||||
/demo/
|
||||
|
||||
# Local Gitea credentials used by development tooling.
|
||||
/gitea.env
|
||||
/config.yaml
|
||||
/server/config/settings.local.yml
|
||||
|
||||
# Generated build and local environment artifacts.
|
||||
/server/go-admin
|
||||
/server/go-admin.exe
|
||||
/server/*.db
|
||||
/server/logs/
|
||||
/server/temp/
|
||||
/web/node_modules/
|
||||
/web/dist/
|
||||
/web/temp/
|
||||
/android/.gradle/
|
||||
/android/local.properties
|
||||
/android/**/build/
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent 开发规则
|
||||
|
||||
本仓库采用 DevHarness 工作流:Gitea 工单记录任务过程,Git 保存源码与版本绑定文档。当前长期文档事实来源是仓库 `docs/`;接入 Gitea Wiki 同步后再切换为 Wiki 主源,切换必须单独建单。
|
||||
|
||||
开始工作前阅读 `docs/00-project-profile.md`、`docs/03-business-rules-and-glossary.md` 和当前工单。不同交付单元规则不同时,在 `server/`、`web/` 或 `android/` 下增加更具体的 `AGENTS.md`。
|
||||
|
||||
## 永久规则
|
||||
|
||||
- 不把密码、Token、Cookie、私钥、PDD 账号凭据、个人数据或生产数据写入代码、日志、工单和文档。
|
||||
- 不执行付款;任何自动支付实现、入口或测试都禁止进入本项目。
|
||||
- 当前 MVP 只实现 PDD 商品、规则、任务、Android 执行和任务详情,不提前实现顺云宝、Shopee、采购、批量任务、规则发布流程、全局停机或实时屏幕。
|
||||
- 不保存原始控件树和设备截图;只保存结构化任务日志、错误码、任务规则快照和采集结果。
|
||||
- 一台设备同一时刻只执行一个任务;手机离线时当前任务失败,默认不重试、不自动换机。
|
||||
- 找不到控件、验证码、风控、人机验证或登录失效时明确失败,不使用 OCR/VLM,不猜测规格,不点击相近候选。
|
||||
- SKU 数据不完整仍须提交并允许在任务详情查看,状态记为 `completed_partial`。
|
||||
- 规则创建即生效;删除后不能创建新任务,但已有任务继续使用自身规则快照。
|
||||
- 同一 PDD 商品不能同时存在多个 `pending` 或 `running` 任务。
|
||||
- 重置任务保留 URL、goods_id、规则和设备快照,事务清空旧结果后重新进入 `pending`。
|
||||
- 保留与当前工单无关的工作区改动,不重置、不覆盖、不顺手修改。
|
||||
|
||||
## 工单门禁
|
||||
|
||||
- 新功能、缺陷、接口、数据库、权限、并发、状态机和 UI 变化必须先有单元工单。
|
||||
- Epic 和 MVP 只维护目标与子工单索引;单元任务是唯一实施单位。
|
||||
- 工单必须记录原始需求摘要、前置依赖、是否可并行、子项目影响、方案、验收、验证、风险和文档影响。
|
||||
- 实施前检查依赖;真实依赖未满足时保持待实施。
|
||||
- 用户未明确验收前,工单保持待验收,不关闭。
|
||||
|
||||
## 实施规则
|
||||
|
||||
1. 先确认目标、非目标和当前事实。
|
||||
2. 只修改工单声明的交付单元和共享契约。
|
||||
3. 共享 API 以 `docs/08-agent-api-contract.md` 为唯一事实来源。
|
||||
4. 数据库和接口变化必须同步更新架构、业务规则和 API 文档。
|
||||
5. 每个动作结果必须与 `taskId`、`deviceId` 和任务内的 `ruleSnapshot` 关联。
|
||||
6. Android Agent 必须使用任务租约和本地互斥锁保证串行。
|
||||
7. 服务端必须以最终包名、Activity 和页面证据验证动作,不只相信 Portal 的 success 响应。
|
||||
8. 测试结果必须真实;未验证的真机、多设备和云环境行为写入工单。
|
||||
|
||||
## Git 与验收
|
||||
|
||||
- 提交只包含当前工单内容,提交信息引用工单号。
|
||||
- 优先运行项目档案记录的格式、单元、契约和集成测试。
|
||||
- 涉及自动提交订单、权限、安全、并发、迁移和删除数据属于高风险,必须再次等待人工确认。
|
||||
- 完成后将实现、验证、遗留问题和提交哈希回写工单,等待用户验收。
|
||||
@@ -0,0 +1,5 @@
|
||||
# Claude Code 项目入口
|
||||
|
||||
@AGENTS.md
|
||||
|
||||
共同开发规则以 `AGENTS.md` 为唯一入口。本文件不重复业务规则;Claude Code 在修改前必须读取当前工单和受影响交付单元文档。
|
||||
@@ -0,0 +1,41 @@
|
||||
# GoAuto 移动采集管理平台
|
||||
|
||||
GoAuto 使用 go-admin、go-admin-ui 和 Android Portal/Agent 跑通 PDD 商品采集闭环:人工添加 PDD 链接、创建采集规则、创建任务、由 Android 执行并在任务详情查看结构化结果。
|
||||
|
||||
当前 MVP 只包含 PDD 商品、采集规则、采集任务、Android 执行和任务详情结果。顺云宝、Shopee、采购、批量任务、规则发布流程、全局停机和实时屏幕均不在当前范围。
|
||||
|
||||
## 阅读入口
|
||||
|
||||
1. [项目档案](docs/00-project-profile.md)
|
||||
2. [开发工作流](docs/01-workflow.md)
|
||||
3. [架构与代码地图](docs/02-architecture-and-code-map.md)
|
||||
4. [业务规则与术语](docs/03-business-rules-and-glossary.md)
|
||||
5. [本地开发与验证](docs/04-local-development-and-verification.md)
|
||||
6. [常见修改](docs/05-common-changes.md)
|
||||
7. [故障排查](docs/06-troubleshooting.md)
|
||||
8. [MVP 需求](docs/07-mvp-requirements.md)
|
||||
9. [共享 API 契约](docs/08-agent-api-contract.md)
|
||||
10. [一期交付工单索引](docs/09-delivery-issues.md)
|
||||
11. [交互原型](prototypes/README.md)
|
||||
|
||||
旧版 ADB/Portal 验证代码、日志和诊断资料已归档到本机 `demo/`,该目录不进入 Git。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- 需求和技术边界已确认。
|
||||
- Harness Coding 文档和工单已建立。
|
||||
- 服务端与 Android 端为可交互 HTML 原型。
|
||||
- `server/` 已固定导入 go-admin `v2.3.0`。
|
||||
- `web/` 已固定导入 go-admin-ui `v3.0.0`。
|
||||
- `android/` 已建立 Kotlin/SDK 34 Agent 骨架。
|
||||
- 最小闭环 8 张业务表、迁移和唯一约束测试已经落地。
|
||||
- 设备首次自注册、Token 认证、停用和吊销接口已经落地。
|
||||
- Token 心跳、在线/离线、空闲/忙碌和离线任务失败已经落地。
|
||||
|
||||
## 验证
|
||||
|
||||
```powershell
|
||||
.\scripts\verify.ps1 -Component all
|
||||
```
|
||||
|
||||
也可以把 `all` 改为 `server`、`web` 或 `android` 单独验证。
|
||||
@@ -0,0 +1,28 @@
|
||||
# Android Agent
|
||||
|
||||
一期 Android 执行端,包名为 `cn.ilapage.goauto.agent`。
|
||||
|
||||
当前已经实现:
|
||||
|
||||
- 首次安装生成并持久化随机 UUID `installId`,不读取硬件唯一标识。
|
||||
- 自动注册和已认证的版本信息更新,无注册码输入页。
|
||||
- Android Keystore AES-GCM 加密保存 Device Token,界面和日志不显示明文。
|
||||
- 前台服务、15 秒心跳、网络恢复触发重连和最小诊断状态页。
|
||||
- 清除 App 数据后按新的安装实例注册;Token 无效时明确报错,不降级为无认证注册。
|
||||
|
||||
服务端地址可在诊断页保存,也可在构建时通过 `GOAUTO_SERVER_URL` 环境变量或同名 Gradle 属性写入默认值。Release 版本只接受 HTTPS;只有 Debug 构建的独立清单允许 HTTP,便于局域网真机联调。诊断页在前台每秒刷新连接状态,但不会显示 Token 明文。
|
||||
|
||||
## T05 真机验证记录
|
||||
|
||||
- 一加 PKG110 / Android 16:首次注册、15 秒心跳、前台通知和在线空闲状态已验证。
|
||||
- 杀死进程后重新打开:installId 和 deviceId 保持不变,设备表仍只有一条记录,心跳恢复。
|
||||
- PDD 8.17.0 版本信息已随注册提交。
|
||||
- ColorOS 首次 ADB 安装需要在“安装增强防护”页执行“更多 → 开始深度扫描 → 继续安装”。
|
||||
- 当前 MVP 只完成并维护一加/ColorOS 真机兼容,不包含华为 ROM。
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat test
|
||||
.\gradlew.bat assembleDebug
|
||||
```
|
||||
|
||||
APK 输出:`app/build/outputs/apk/debug/app-debug.apk`。AccessibilityService、任务领取和 PDD 页面执行由后续工单实现。
|
||||
@@ -0,0 +1,52 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "cn.ilapage.goauto.agent"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
val configuredServerUrl = providers.environmentVariable("GOAUTO_SERVER_URL")
|
||||
.orElse(providers.gradleProperty("GOAUTO_SERVER_URL"))
|
||||
.orElse("")
|
||||
.get()
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
buildConfigField("String", "DEFAULT_SERVER_URL", "\"$configuredServerUrl\"")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.json:json:20240303")
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
# MVP skeleton: add release rules when the Agent networking layer is introduced.
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application android:usesCleartextTraffic="true" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<queries>
|
||||
<package android:name="com.xunmeng.pinduoduo" />
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.GoAutoAgent">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".service.AgentForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<service
|
||||
android:name=".automation.GoAutoAccessibilityService"
|
||||
android:exported="true"
|
||||
android:label="@string/accessibility_service_name"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/accessibility_service_config" />
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,177 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.InputType
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import cn.ilapage.goauto.agent.identity.SecureDeviceStore
|
||||
import cn.ilapage.goauto.agent.service.AgentForegroundService
|
||||
import cn.ilapage.goauto.agent.service.AgentSettingsStore
|
||||
import cn.ilapage.goauto.agent.service.AgentStateStore
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
class MainActivity : Activity() {
|
||||
private val statusHandler = Handler(Looper.getMainLooper())
|
||||
private val statusRefresher = object : Runnable {
|
||||
override fun run() {
|
||||
refreshStatus()
|
||||
statusHandler.postDelayed(this, STATUS_REFRESH_MILLIS)
|
||||
}
|
||||
}
|
||||
private lateinit var settingsStore: AgentSettingsStore
|
||||
private lateinit var stateStore: AgentStateStore
|
||||
private lateinit var identityStore: SecureDeviceStore
|
||||
private lateinit var serverUrlInput: EditText
|
||||
private lateinit var stateText: TextView
|
||||
private lateinit var detailText: TextView
|
||||
private lateinit var identityText: TextView
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
settingsStore = AgentSettingsStore(this)
|
||||
stateStore = AgentStateStore(this)
|
||||
identityStore = SecureDeviceStore(this)
|
||||
setContentView(buildContent())
|
||||
requestNotificationPermission()
|
||||
if (settingsStore.serverUrl().isNotBlank()) AgentForegroundService.start(this)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
statusHandler.removeCallbacks(statusRefresher)
|
||||
statusHandler.post(statusRefresher)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
statusHandler.removeCallbacks(statusRefresher)
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
private fun buildContent(): ScrollView {
|
||||
val density = resources.displayMetrics.density
|
||||
val padding = (20 * density).toInt()
|
||||
val content = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(padding, padding, padding, padding)
|
||||
setBackgroundColor(BACKGROUND)
|
||||
}
|
||||
content.addView(text("Mobile Agent", 28f, Color.WHITE, true))
|
||||
content.addView(text("PDD 采集执行端", 15f, MUTED))
|
||||
content.addView(sectionTitle("连接状态"))
|
||||
stateText = text("—", 22f, GREEN, true)
|
||||
detailText = text("—", 15f, MUTED)
|
||||
content.addView(stateText)
|
||||
content.addView(detailText)
|
||||
content.addView(sectionTitle("设备身份"))
|
||||
identityText = text("—", 14f, Color.WHITE)
|
||||
content.addView(identityText)
|
||||
content.addView(sectionTitle("服务端地址"))
|
||||
serverUrlInput = EditText(this).apply {
|
||||
hint = if (BuildConfig.DEBUG) "https://example.com(调试版也允许 http)" else "https://example.com"
|
||||
setText(settingsStore.serverUrl())
|
||||
setTextColor(Color.WHITE)
|
||||
setHintTextColor(MUTED)
|
||||
setBackgroundColor(CARD)
|
||||
setPadding(padding / 2, padding / 2, padding / 2, padding / 2)
|
||||
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
|
||||
contentDescription = "GoAuto 服务端地址"
|
||||
}
|
||||
content.addView(serverUrlInput, matchWrap())
|
||||
content.addView(button("保存并连接") {
|
||||
runCatching {
|
||||
settingsStore.saveServerUrl(serverUrlInput.text.toString())
|
||||
AgentForegroundService.start(this, reconnect = true)
|
||||
"已保存,正在连接"
|
||||
}.onSuccess {
|
||||
stateText.text = "正在连接"
|
||||
detailText.text = it
|
||||
}.onFailure {
|
||||
stateText.text = "配置错误"
|
||||
detailText.text = it.message
|
||||
}
|
||||
})
|
||||
content.addView(button("刷新诊断状态") { refreshStatus() })
|
||||
content.addView(button("打开无障碍设置") {
|
||||
startActivity(android.content.Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||
})
|
||||
content.addView(text("Token 使用 Android Keystore 加密保存,本页面不会显示明文。清除 App 数据后会生成新的 installId,并按新设备注册。", 13f, MUTED))
|
||||
return ScrollView(this).apply { addView(content, matchWrap()) }
|
||||
}
|
||||
|
||||
private fun refreshStatus() {
|
||||
val state = stateStore.read()
|
||||
stateText.text = state.code
|
||||
stateText.setTextColor(if (state.code in setOf("ONLINE", "BUSY")) GREEN else AMBER)
|
||||
val heartbeat = if (state.lastHeartbeatAt > 0L) {
|
||||
DateFormat.getDateTimeInstance().format(Date(state.lastHeartbeatAt))
|
||||
} else {
|
||||
"尚无成功心跳"
|
||||
}
|
||||
detailText.text = "${state.message}\n最近心跳:$heartbeat"
|
||||
val installId = runCatching { identityStore.installId() }.getOrElse { "生成失败" }
|
||||
identityText.text = buildString {
|
||||
append("installId:$installId\n")
|
||||
append("deviceId:${state.deviceId.takeIf { it > 0 } ?: "未注册"}\n")
|
||||
append("Device Token:${if (state.tokenStored) "已安全保存" else "未签发"}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 100)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sectionTitle(value: String) = text(value, 16f, Color.WHITE, true).apply {
|
||||
setPadding(0, 28, 0, 8)
|
||||
}
|
||||
|
||||
private fun text(value: String, size: Float, color: Int, bold: Boolean = false) = TextView(this).apply {
|
||||
text = value
|
||||
textSize = size
|
||||
setTextColor(color)
|
||||
if (bold) setTypeface(typeface, android.graphics.Typeface.BOLD)
|
||||
setPadding(0, 7, 0, 7)
|
||||
layoutParams = matchWrap()
|
||||
}
|
||||
|
||||
private fun button(label: String, action: () -> Unit) = Button(this).apply {
|
||||
text = label
|
||||
isAllCaps = false
|
||||
setTextColor(Color.WHITE)
|
||||
setBackgroundColor(GREEN_DARK)
|
||||
gravity = Gravity.CENTER
|
||||
minHeight = (48 * resources.displayMetrics.density).toInt()
|
||||
setOnClickListener { action() }
|
||||
layoutParams = matchWrap().apply { setMargins(0, 16, 0, 0) }
|
||||
}
|
||||
|
||||
private fun matchWrap() = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val BACKGROUND = Color.rgb(2, 6, 23)
|
||||
val CARD = Color.rgb(15, 23, 42)
|
||||
val MUTED = Color.rgb(148, 163, 184)
|
||||
val GREEN = Color.rgb(74, 222, 128)
|
||||
val GREEN_DARK = Color.rgb(21, 128, 61)
|
||||
val AMBER = Color.rgb(251, 191, 36)
|
||||
const val STATUS_REFRESH_MILLIS = 1_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
data class ProductSummary(
|
||||
val pddGoodsId: String,
|
||||
val title: String?,
|
||||
val shopName: String?,
|
||||
val salesText: String?,
|
||||
val reviewCount: Long?,
|
||||
)
|
||||
|
||||
data class SpecDimension(val key: String, val name: String, val values: List<String>)
|
||||
data class ColorPrice(val color: String, val priceCent: Long)
|
||||
data class CollectedSku(val specs: Map<String, String>, val priceCent: Long, val available: Boolean = true)
|
||||
|
||||
data class CollectionPayload(
|
||||
val status: String,
|
||||
val product: ProductSummary,
|
||||
val dimensions: List<SpecDimension>,
|
||||
val colorPrices: List<ColorPrice>,
|
||||
val skus: List<CollectedSku>,
|
||||
val missing: List<String>,
|
||||
)
|
||||
|
||||
object CollectionAssembler {
|
||||
fun assemble(goodsId: String, extracted: Map<String, List<String>>): CollectionPayload {
|
||||
val title = extracted.first("title")
|
||||
val shopName = extracted.first("shopName")
|
||||
val salesText = extracted.first("salesText")
|
||||
val reviewCount = extracted.first("reviewCount")?.let(::parseCount)
|
||||
val colors = extracted.values("color")
|
||||
val sizes = extracted.values("size")
|
||||
val prices = parseColorPrices(extracted, colors)
|
||||
val missing = mutableListOf<String>()
|
||||
if (title == null) missing += "title"
|
||||
if (shopName == null) missing += "shopName"
|
||||
if (salesText == null) missing += "salesText"
|
||||
if (reviewCount == null) missing += "reviewCount"
|
||||
if (colors.isEmpty()) missing += "color"
|
||||
if (sizes.isEmpty()) missing += "size"
|
||||
colors.filterNot(prices::containsKey).forEach { missing += "price:$it" }
|
||||
|
||||
val dimensions = buildList {
|
||||
if (colors.isNotEmpty()) add(SpecDimension("color", "颜色", colors))
|
||||
if (sizes.isNotEmpty()) add(SpecDimension("size", "尺码", sizes))
|
||||
}
|
||||
val colorPrices = colors.mapNotNull { color -> prices[color]?.let { ColorPrice(color, it) } }
|
||||
val skus = colorPrices.flatMap { colorPrice ->
|
||||
if (sizes.isEmpty()) {
|
||||
listOf(CollectedSku(mapOf("color" to colorPrice.color), colorPrice.priceCent))
|
||||
} else {
|
||||
sizes.map { size ->
|
||||
CollectedSku(mapOf("color" to colorPrice.color, "size" to size), colorPrice.priceCent)
|
||||
}
|
||||
}
|
||||
}
|
||||
return CollectionPayload(
|
||||
status = if (missing.isEmpty()) "completed" else "completed_partial",
|
||||
product = ProductSummary(goodsId, title, shopName, salesText, reviewCount),
|
||||
dimensions = dimensions,
|
||||
colorPrices = colorPrices,
|
||||
skus = skus,
|
||||
missing = missing,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseColorPrices(extracted: Map<String, List<String>>, colors: List<String>): Map<String, Long> {
|
||||
val explicit = extracted.values("colorPrice").mapNotNull { raw ->
|
||||
val separator = listOf('|', '=', ':', ':').firstOrNull(raw::contains) ?: return@mapNotNull null
|
||||
val parts = raw.split(separator, limit = 2)
|
||||
parsePrice(parts[1])?.let { parts[0].trim() to it }
|
||||
}.toMap()
|
||||
if (explicit.isNotEmpty()) return explicit
|
||||
return colors.zip(extracted.values("price").mapNotNull(::parsePrice)).toMap()
|
||||
}
|
||||
|
||||
internal fun parsePrice(raw: String): Long? {
|
||||
val normalized = Regex("[^0-9.]").replace(raw, "")
|
||||
return runCatching {
|
||||
BigDecimal(normalized).multiply(BigDecimal(100)).setScale(0, RoundingMode.HALF_UP).longValueExact()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
internal fun parseCount(raw: String): Long? {
|
||||
val number = Regex("[0-9]+(?:\\.[0-9]+)?").find(raw)?.value ?: return null
|
||||
val multiplier = when {
|
||||
raw.contains("万") -> BigDecimal(10_000)
|
||||
raw.lowercase().contains("k") -> BigDecimal(1_000)
|
||||
else -> BigDecimal.ONE
|
||||
}
|
||||
return runCatching { BigDecimal(number).multiply(multiplier).setScale(0, RoundingMode.HALF_UP).longValueExact() }.getOrNull()
|
||||
}
|
||||
|
||||
private fun Map<String, List<String>>.first(key: String) = get(key)?.firstOrNull()?.trim()?.takeIf(String::isNotEmpty)
|
||||
private fun Map<String, List<String>>.values(key: String) = get(key).orEmpty().map(String::trim).filter(String::isNotEmpty).distinct()
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.accessibilityservice.AccessibilityServiceInfo
|
||||
import android.os.Bundle
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
|
||||
class GoAutoAccessibilityService : AccessibilityService(), UiDriver {
|
||||
@Volatile
|
||||
private var activeWindowClassName: String? = null
|
||||
|
||||
override fun onServiceConnected() {
|
||||
serviceInfo = serviceInfo.apply {
|
||||
flags = flags or AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS or
|
||||
AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS or
|
||||
AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS
|
||||
}
|
||||
instance = this
|
||||
}
|
||||
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
||||
if (event?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
|
||||
activeWindowClassName = event.className?.toString()
|
||||
}
|
||||
}
|
||||
override fun onInterrupt() = Unit
|
||||
|
||||
override fun onDestroy() {
|
||||
if (instance === this) instance = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun currentPackage(): String? = rootInActiveWindow?.packageName?.toString()
|
||||
override fun currentActivity(): String? = activeWindowClassName
|
||||
|
||||
override fun visibleTexts(): List<String> {
|
||||
val root = rootInActiveWindow ?: return emptyList()
|
||||
val texts = mutableListOf<String>()
|
||||
walk(root) { node ->
|
||||
(node.text ?: node.contentDescription)?.toString()?.trim()?.takeIf { it.isNotEmpty() }?.let(texts::add)
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
override fun find(selector: NodeSelector): List<UiNodeRef> {
|
||||
val root = rootInActiveWindow ?: return emptyList()
|
||||
val matches = mutableListOf<UiNodeRef>()
|
||||
walk(root) { node ->
|
||||
if (node.matches(selector)) matches += UiNodeRef(node, node.text?.toString() ?: node.contentDescription?.toString())
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
override fun click(node: UiNodeRef): Boolean {
|
||||
var target = node.opaqueId as? AccessibilityNodeInfo ?: return false
|
||||
while (!target.isClickable) {
|
||||
target = target.parent ?: return false
|
||||
}
|
||||
return target.performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
||||
}
|
||||
|
||||
override fun input(node: UiNodeRef, value: String): Boolean {
|
||||
val args = Bundle().apply {
|
||||
putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, value)
|
||||
}
|
||||
return (node.opaqueId as? AccessibilityNodeInfo)?.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args) == true
|
||||
}
|
||||
|
||||
override fun back(): Boolean = performGlobalAction(GLOBAL_ACTION_BACK)
|
||||
|
||||
private fun walk(node: AccessibilityNodeInfo, visit: (AccessibilityNodeInfo) -> Unit) {
|
||||
visit(node)
|
||||
for (index in 0 until node.childCount) node.getChild(index)?.let { walk(it, visit) }
|
||||
}
|
||||
|
||||
private fun AccessibilityNodeInfo.matches(selector: NodeSelector): Boolean =
|
||||
(selector.resourceId == null || viewIdResourceName == selector.resourceId) &&
|
||||
(selector.text == null || text?.toString() == selector.text) &&
|
||||
(selector.contentDescription == null || contentDescription?.toString() == selector.contentDescription) &&
|
||||
(selector.className == null || className?.toString() == selector.className) &&
|
||||
(selector.clickable == null || isClickable == selector.clickable)
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
var instance: GoAutoAccessibilityService? = null
|
||||
private set
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
|
||||
data class PageProblem(val code: String, val message: String)
|
||||
|
||||
object PddPageClassifier {
|
||||
fun classify(packageName: String?, activityName: String?, visibleTexts: Collection<String>): PageProblem? {
|
||||
val text = visibleTexts.joinToString(" ").lowercase()
|
||||
return when {
|
||||
packageName == PDD_PACKAGE && isLoginActivity(activityName) ->
|
||||
PageProblem("PDD_LOGIN_REQUIRED", "拼多多登录已失效,请重新登录")
|
||||
packageName == PDD_PACKAGE && listOf("验证码", "人机验证", "完成验证", "滑动验证", "安全验证").any(text::contains) ->
|
||||
PageProblem("PDD_CAPTCHA_REQUIRED", "页面要求验证码或人机验证,请人工处理")
|
||||
packageName == PDD_PACKAGE && listOf("账号存在风险", "访问过于频繁", "操作频繁", "风险提示", "异常访问").any(text::contains) ->
|
||||
PageProblem("PDD_RISK_CONTROL", "拼多多触发风控,请人工检查账号或设备")
|
||||
packageName == PDD_PACKAGE && listOf("登录后继续", "手机号登录", "登录拼多多").any(text::contains) ->
|
||||
PageProblem("PDD_LOGIN_REQUIRED", "拼多多登录已失效,请重新登录")
|
||||
listOf("商品已下架", "商品不存在", "链接已失效", "页面不存在").any(text::contains) ->
|
||||
PageProblem("PDD_LINK_INVALID", "商品链接无效或商品已下架")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLoginActivity(activityName: String?): Boolean =
|
||||
activityName?.let { it.endsWith("LoginActivity") || it.contains(".login.", ignoreCase = true) } == true
|
||||
|
||||
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
}
|
||||
|
||||
class PddLinkLauncher(private val context: Context) {
|
||||
fun open(url: String): Boolean {
|
||||
val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false
|
||||
if (uri.scheme !in setOf("http", "https") || !isPddHost(uri.host) || uri.getQueryParameter("goods_id").isNullOrBlank()) {
|
||||
return false
|
||||
}
|
||||
val base = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val browser = BROWSER_PACKAGES.firstOrNull { packageName ->
|
||||
runCatching { context.packageManager.getPackageInfo(packageName, 0) }.isSuccess
|
||||
}
|
||||
if (browser != null) base.setPackage(browser)
|
||||
return runCatching { context.startActivity(base); true }.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun isPddHost(host: String?): Boolean =
|
||||
host == "yangkeduo.com" || host?.endsWith(".yangkeduo.com") == true
|
||||
|
||||
companion object {
|
||||
private val BROWSER_PACKAGES = listOf(
|
||||
"com.heytap.browser",
|
||||
"com.android.chrome",
|
||||
"com.android.browser",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
enum class RuleAction { WAIT, CLICK, INPUT, BACK, EXTRACT }
|
||||
|
||||
data class NodeSelector(
|
||||
val resourceId: String? = null,
|
||||
val text: String? = null,
|
||||
val contentDescription: String? = null,
|
||||
val className: String? = null,
|
||||
val clickable: Boolean? = null,
|
||||
) {
|
||||
fun isEmpty() = resourceId == null && text == null && contentDescription == null &&
|
||||
className == null && clickable == null
|
||||
}
|
||||
|
||||
data class RuleStep(
|
||||
val id: String,
|
||||
val page: String,
|
||||
val packageName: String,
|
||||
val activityName: String?,
|
||||
val action: RuleAction,
|
||||
val selector: NodeSelector?,
|
||||
val value: String?,
|
||||
val field: String?,
|
||||
val timeoutMs: Long,
|
||||
val optional: Boolean,
|
||||
)
|
||||
|
||||
data class CollectionRule(val schemaVersion: Int, val steps: List<RuleStep>)
|
||||
|
||||
class RuleValidationException(val code: String, message: String) : IllegalArgumentException(message)
|
||||
|
||||
object RuleParser {
|
||||
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
private val allowedPackages = setOf(
|
||||
"com.xunmeng.pinduoduo",
|
||||
"com.android.chrome",
|
||||
"com.heytap.browser",
|
||||
"com.android.browser",
|
||||
"android",
|
||||
)
|
||||
private val forbiddenWords = listOf("立即支付", "确认支付", "付款", "提交订单", "免密支付", "pay now")
|
||||
|
||||
fun parse(raw: String): CollectionRule {
|
||||
val root = try {
|
||||
JSONObject(raw)
|
||||
} catch (_: Exception) {
|
||||
throw RuleValidationException("RULE_INVALID", "规则不是有效的 JSON 对象")
|
||||
}
|
||||
if (root.optInt("schemaVersion", -1) != 1) {
|
||||
throw RuleValidationException("RULE_VERSION_UNSUPPORTED", "仅支持 schemaVersion 1")
|
||||
}
|
||||
val defaultPackage = root.optString("packageName").takeIf { it.isNotBlank() }
|
||||
val items = root.optJSONArray("steps")
|
||||
?: throw RuleValidationException("RULE_INVALID", "steps 必须是非空数组")
|
||||
if (items.length() == 0) throw RuleValidationException("RULE_INVALID", "steps 必须是非空数组")
|
||||
val ids = mutableSetOf<String>()
|
||||
val steps = (0 until items.length()).map { index ->
|
||||
val item = items.optJSONObject(index)
|
||||
?: throw RuleValidationException("RULE_INVALID", "steps[$index] 必须是对象")
|
||||
val id = item.optString("id").trim()
|
||||
if (id.isBlank() || !ids.add(id)) throw RuleValidationException("RULE_INVALID", "步骤 id 为空或重复")
|
||||
val action = runCatching { RuleAction.valueOf(item.optString("action").uppercase()) }
|
||||
.getOrElse { throw RuleValidationException("RULE_ACTION_NOT_ALLOWED", "步骤 $id 的 action 不受支持") }
|
||||
val packageName = item.optString("packageName").takeIf { it.isNotBlank() } ?: defaultPackage
|
||||
if (packageName !in allowedPackages) {
|
||||
throw RuleValidationException("RULE_PACKAGE_NOT_ALLOWED", "步骤 $id 的应用包不在白名单")
|
||||
}
|
||||
val selectorJson = item.optJSONObject("selector")
|
||||
val selector = selectorJson?.let {
|
||||
NodeSelector(
|
||||
resourceId = it.stringOrNull("resourceId"),
|
||||
text = it.stringOrNull("text"),
|
||||
contentDescription = it.stringOrNull("contentDescription"),
|
||||
className = it.stringOrNull("className"),
|
||||
clickable = if (it.has("clickable")) it.getBoolean("clickable") else null,
|
||||
)
|
||||
}
|
||||
if (action != RuleAction.BACK && (selector == null || selector.isEmpty())) {
|
||||
throw RuleValidationException("RULE_INVALID", "步骤 $id 缺少 selector")
|
||||
}
|
||||
if (action == RuleAction.CLICK && packageName != PDD_PACKAGE) {
|
||||
val target = selector?.text ?: selector?.contentDescription
|
||||
if (target !in setOf("打开拼多多APP", "打开拼多多 App", "打开")) {
|
||||
throw RuleValidationException("RULE_ACTION_NOT_ALLOWED", "步骤 $id 不是允许的打开拼多多动作")
|
||||
}
|
||||
}
|
||||
val value = item.stringOrNull("value")
|
||||
val field = item.stringOrNull("field")
|
||||
if (action == RuleAction.INPUT && value == null) throw RuleValidationException("RULE_INVALID", "输入步骤 $id 缺少 value")
|
||||
if (action == RuleAction.EXTRACT && field == null) throw RuleValidationException("RULE_INVALID", "采集步骤 $id 缺少 field")
|
||||
val safetyText = listOf(id, selector?.text, selector?.contentDescription, value, field)
|
||||
.filterNotNull().joinToString(" ").lowercase()
|
||||
if (forbiddenWords.any(safetyText::contains)) {
|
||||
throw RuleValidationException("FORBIDDEN_ACTION", "步骤 $id 涉及支付或下单操作")
|
||||
}
|
||||
RuleStep(
|
||||
id = id,
|
||||
page = item.optString("page", "default").take(80),
|
||||
packageName = requireNotNull(packageName),
|
||||
activityName = item.stringOrNull("activityName"),
|
||||
action = action,
|
||||
selector = selector,
|
||||
value = value,
|
||||
field = field,
|
||||
timeoutMs = item.optLong("timeoutMs", 5_000).coerceIn(100, 30_000),
|
||||
optional = item.optBoolean("optional", false),
|
||||
)
|
||||
}
|
||||
return CollectionRule(1, steps)
|
||||
}
|
||||
|
||||
private fun JSONObject.stringOrNull(name: String): String? =
|
||||
optString(name).trim().takeIf { it.isNotBlank() }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
data class UiNodeRef(val opaqueId: Any, val text: String?)
|
||||
|
||||
interface UiDriver {
|
||||
fun currentPackage(): String?
|
||||
fun currentActivity(): String?
|
||||
fun visibleTexts(): List<String>
|
||||
fun find(selector: NodeSelector): List<UiNodeRef>
|
||||
fun click(node: UiNodeRef): Boolean
|
||||
fun input(node: UiNodeRef, value: String): Boolean
|
||||
fun back(): Boolean
|
||||
}
|
||||
|
||||
data class RuleExecutionResult(
|
||||
val successful: Boolean,
|
||||
val code: String,
|
||||
val message: String,
|
||||
val extracted: Map<String, List<String>> = emptyMap(),
|
||||
)
|
||||
|
||||
class RuleExecutor(
|
||||
private val driver: UiDriver,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
) {
|
||||
fun execute(rule: CollectionRule): RuleExecutionResult {
|
||||
val extracted = linkedMapOf<String, MutableList<String>>()
|
||||
for (step in rule.steps) {
|
||||
val deadline = now() + step.timeoutMs
|
||||
var lastFailure = "控件未出现"
|
||||
while (now() <= deadline) {
|
||||
val currentPackage = driver.currentPackage()
|
||||
val currentActivity = driver.currentActivity()
|
||||
PddPageClassifier.classify(
|
||||
currentPackage,
|
||||
currentActivity,
|
||||
driver.visibleTexts(),
|
||||
)?.let { problem ->
|
||||
return RuleExecutionResult(false, problem.code, problem.message, extracted)
|
||||
}
|
||||
if (currentPackage != step.packageName) {
|
||||
lastFailure = "当前应用与规则不匹配"
|
||||
} else if (step.activityName != null && currentActivity != step.activityName) {
|
||||
lastFailure = "当前 Activity 与规则不匹配"
|
||||
} else {
|
||||
val result = executeOnce(step, extracted)
|
||||
if (result == null) break
|
||||
lastFailure = result
|
||||
if (result == "控件匹配到多个节点") return failure(step, "RULE_AMBIGUOUS", result, extracted)
|
||||
if (result == "操作执行失败") return failure(step, "RULE_ACTION_FAILED", result, extracted)
|
||||
}
|
||||
if (now() >= deadline) {
|
||||
if (step.optional) break
|
||||
return failure(step, "RULE_NOT_MATCHED", lastFailure, extracted)
|
||||
}
|
||||
pause(minOf(100, (deadline - now()).coerceAtLeast(1)))
|
||||
}
|
||||
}
|
||||
return RuleExecutionResult(true, "OK", "规则执行完成", extracted.mapValues { it.value.toList() })
|
||||
}
|
||||
|
||||
private fun executeOnce(step: RuleStep, output: MutableMap<String, MutableList<String>>): String? {
|
||||
if (step.action == RuleAction.BACK) return if (driver.back()) null else "操作执行失败"
|
||||
val matches = driver.find(requireNotNull(step.selector))
|
||||
if (matches.isEmpty()) return "控件未出现"
|
||||
if (matches.size != 1) return "控件匹配到多个节点"
|
||||
val node = matches.single()
|
||||
return when (step.action) {
|
||||
RuleAction.WAIT -> null
|
||||
RuleAction.CLICK -> if (driver.click(node)) null else "操作执行失败"
|
||||
RuleAction.INPUT -> if (driver.input(node, requireNotNull(step.value))) null else "操作执行失败"
|
||||
RuleAction.EXTRACT -> {
|
||||
val value = node.text?.trim().orEmpty()
|
||||
if (value.isEmpty()) "控件文本为空" else {
|
||||
output.getOrPut(requireNotNull(step.field)) { mutableListOf() }.add(value)
|
||||
null
|
||||
}
|
||||
}
|
||||
RuleAction.BACK -> error("handled above")
|
||||
}
|
||||
}
|
||||
|
||||
private fun failure(step: RuleStep, code: String, detail: String, output: Map<String, List<String>>) =
|
||||
RuleExecutionResult(false, code, "步骤 ${step.id}(${step.page})失败:$detail", output)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package cn.ilapage.goauto.agent.identity
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.security.KeyStore
|
||||
import java.util.UUID
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
data class DeviceCredentials(val deviceId: Long, val token: String)
|
||||
|
||||
class SecureDeviceStore(context: Context) {
|
||||
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
|
||||
fun installId(): String {
|
||||
preferences.getString(INSTALL_ID, null)?.let { return it }
|
||||
val generated = UUID.randomUUID().toString()
|
||||
if (!preferences.edit().putString(INSTALL_ID, generated).commit()) {
|
||||
error("无法持久化 installId")
|
||||
}
|
||||
return generated
|
||||
}
|
||||
|
||||
fun credentials(): DeviceCredentials? {
|
||||
val deviceId = preferences.getLong(DEVICE_ID, 0L)
|
||||
val encrypted = preferences.getString(DEVICE_TOKEN, null)
|
||||
if (deviceId <= 0L || encrypted.isNullOrBlank()) return null
|
||||
return DeviceCredentials(deviceId, decrypt(encrypted))
|
||||
}
|
||||
|
||||
fun saveCredentials(deviceId: Long, token: String) {
|
||||
require(deviceId > 0L)
|
||||
require(token.isNotBlank())
|
||||
if (!preferences.edit()
|
||||
.putLong(DEVICE_ID, deviceId)
|
||||
.putString(DEVICE_TOKEN, encrypt(token))
|
||||
.commit()
|
||||
) {
|
||||
error("无法安全保存设备凭据")
|
||||
}
|
||||
}
|
||||
|
||||
private fun encrypt(plainText: String): String {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey())
|
||||
val encrypted = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))
|
||||
return Base64.encodeToString(cipher.iv + encrypted, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
private fun decrypt(encoded: String): String {
|
||||
val packed = Base64.decode(encoded, Base64.NO_WRAP)
|
||||
require(packed.size > IV_SIZE) { "设备凭据已损坏" }
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, encryptionKey(), GCMParameterSpec(TAG_BITS, packed, 0, IV_SIZE))
|
||||
return cipher.doFinal(packed, IV_SIZE, packed.size - IV_SIZE).toString(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun encryptionKey(): SecretKey {
|
||||
val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }
|
||||
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
|
||||
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
|
||||
generator.init(
|
||||
KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setRandomizedEncryptionRequired(true)
|
||||
.build(),
|
||||
)
|
||||
return generator.generateKey()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFERENCES = "goauto_device_identity"
|
||||
const val INSTALL_ID = "install_id"
|
||||
const val DEVICE_ID = "device_id"
|
||||
const val DEVICE_TOKEN = "device_token_encrypted"
|
||||
const val KEYSTORE = "AndroidKeyStore"
|
||||
const val KEY_ALIAS = "goauto_device_token_v1"
|
||||
const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
const val IV_SIZE = 12
|
||||
const val TAG_BITS = 128
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package cn.ilapage.goauto.agent.network
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.CollectionPayload
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.UUID
|
||||
|
||||
data class DeviceInfo(
|
||||
val installId: String,
|
||||
val name: String,
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val androidVersion: String,
|
||||
val agentVersion: String,
|
||||
val pddVersion: String,
|
||||
)
|
||||
|
||||
data class RegistrationResult(
|
||||
val deviceId: Long,
|
||||
val deviceToken: String?,
|
||||
val heartbeatIntervalSeconds: Int,
|
||||
)
|
||||
|
||||
data class HeartbeatResult(
|
||||
val deviceId: Long,
|
||||
val online: Boolean,
|
||||
val busy: Boolean,
|
||||
val heartbeatIntervalSeconds: Int,
|
||||
)
|
||||
|
||||
data class AgentTask(
|
||||
val taskId: Long,
|
||||
val pddProductId: Long,
|
||||
val urlSnapshot: String,
|
||||
val goodsIdSnapshot: String,
|
||||
val ruleId: Long,
|
||||
val ruleSnapshot: String,
|
||||
val timeoutSeconds: Int,
|
||||
val leaseVersion: Long,
|
||||
val status: String,
|
||||
)
|
||||
|
||||
class AgentApiException(
|
||||
val status: Int,
|
||||
val code: String,
|
||||
override val message: String,
|
||||
val retryable: Boolean,
|
||||
) : Exception(message)
|
||||
|
||||
class AgentApiClient(private val serverUrl: String) {
|
||||
fun register(info: DeviceInfo, token: String?): RegistrationResult {
|
||||
val payload = JSONObject()
|
||||
.put("requestId", UUID.randomUUID().toString())
|
||||
.put("installId", info.installId)
|
||||
.put("name", info.name)
|
||||
.put("manufacturer", info.manufacturer)
|
||||
.put("model", info.model)
|
||||
.put("androidVersion", info.androidVersion)
|
||||
.put("agentVersion", info.agentVersion)
|
||||
.put("pddVersion", info.pddVersion)
|
||||
val data = post("/api/agent/v1/register", payload, token).getJSONObject("data")
|
||||
return RegistrationResult(
|
||||
deviceId = data.getLong("deviceId"),
|
||||
deviceToken = data.optString("deviceToken").takeIf { it.isNotBlank() },
|
||||
heartbeatIntervalSeconds = data.optInt("heartbeatIntervalSeconds", 15),
|
||||
)
|
||||
}
|
||||
|
||||
fun heartbeat(token: String, currentTaskId: Long?): HeartbeatResult {
|
||||
val payload = JSONObject()
|
||||
.put("requestId", UUID.randomUUID().toString())
|
||||
.put("currentTaskId", currentTaskId ?: JSONObject.NULL)
|
||||
val data = post("/api/agent/v1/heartbeat", payload, token).getJSONObject("data")
|
||||
return HeartbeatResult(
|
||||
deviceId = data.getLong("deviceId"),
|
||||
online = data.getBoolean("online"),
|
||||
busy = data.getBoolean("busy"),
|
||||
heartbeatIntervalSeconds = data.optInt("heartbeatIntervalSeconds", 15),
|
||||
)
|
||||
}
|
||||
|
||||
fun nextTask(token: String): AgentTask? {
|
||||
val response = request("GET", "/api/agent/v1/tasks/next", null, token) ?: return null
|
||||
return task(response.getJSONObject("data"))
|
||||
}
|
||||
|
||||
fun claimTask(taskId: Long, requestId: String, token: String): AgentTask {
|
||||
val payload = JSONObject().put("requestId", requestId)
|
||||
return task(requireNotNull(request("POST", "/api/agent/v1/tasks/$taskId/claim", payload, token)).getJSONObject("data"))
|
||||
}
|
||||
|
||||
fun startTask(taskId: Long, requestId: String, token: String): AgentTask {
|
||||
val payload = JSONObject().put("requestId", requestId)
|
||||
return task(requireNotNull(request("POST", "/api/agent/v1/tasks/$taskId/start", payload, token)).getJSONObject("data"))
|
||||
}
|
||||
|
||||
fun submitResult(taskId: Long, requestId: String, result: CollectionPayload, token: String) {
|
||||
val product = JSONObject()
|
||||
.put("pddGoodsId", result.product.pddGoodsId)
|
||||
.put("title", result.product.title ?: JSONObject.NULL)
|
||||
.put("shopName", result.product.shopName ?: JSONObject.NULL)
|
||||
.put("salesText", result.product.salesText ?: JSONObject.NULL)
|
||||
.put("reviewCount", result.product.reviewCount ?: JSONObject.NULL)
|
||||
val dimensions = JSONArray().apply { result.dimensions.forEach { item -> put(JSONObject().put("key", item.key).put("name", item.name).put("values", JSONArray(item.values))) } }
|
||||
val colorPrices = JSONArray().apply { result.colorPrices.forEach { item -> put(JSONObject().put("color", item.color).put("priceCent", item.priceCent)) } }
|
||||
val skus = JSONArray().apply { result.skus.forEach { item -> put(JSONObject().put("specs", JSONObject(item.specs)).put("priceCent", item.priceCent).put("available", item.available)) } }
|
||||
val payload = JSONObject().put("requestId", requestId).put("status", result.status).put("product", product)
|
||||
.put("dimensions", dimensions).put("colorPrices", colorPrices).put("skus", skus).put("missing", JSONArray(result.missing))
|
||||
requireNotNull(request("POST", "/api/agent/v1/tasks/$taskId/result", payload, token))
|
||||
}
|
||||
|
||||
fun submitFailure(taskId: Long, requestId: String, code: String, message: String, token: String) {
|
||||
val payload = JSONObject().put("requestId", requestId).put("code", code).put("message", message.take(1000))
|
||||
requireNotNull(request("POST", "/api/agent/v1/tasks/$taskId/fail", payload, token))
|
||||
}
|
||||
|
||||
private fun task(data: JSONObject) = AgentTask(
|
||||
taskId = data.getLong("taskId"),
|
||||
pddProductId = data.getLong("pddProductId"),
|
||||
urlSnapshot = data.getString("urlSnapshot"),
|
||||
goodsIdSnapshot = data.getString("goodsIdSnapshot"),
|
||||
ruleId = data.getLong("ruleId"),
|
||||
ruleSnapshot = data.getJSONObject("ruleSnapshot").toString(),
|
||||
timeoutSeconds = data.optInt("timeoutSeconds", 120),
|
||||
leaseVersion = data.getLong("leaseVersion"),
|
||||
status = data.getString("status"),
|
||||
)
|
||||
|
||||
private fun post(path: String, payload: JSONObject, token: String?): JSONObject {
|
||||
return requireNotNull(request("POST", path, payload, token))
|
||||
}
|
||||
|
||||
private fun request(method: String, path: String, payload: JSONObject?, token: String?): JSONObject? {
|
||||
val connection = (URL(serverUrl + path).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = method
|
||||
connectTimeout = 10_000
|
||||
readTimeout = 15_000
|
||||
doOutput = payload != null
|
||||
useCaches = false
|
||||
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
setRequestProperty("Accept", "application/json")
|
||||
setRequestProperty("Cache-Control", "no-store")
|
||||
if (!token.isNullOrBlank()) setRequestProperty("Authorization", "Bearer $token")
|
||||
}
|
||||
try {
|
||||
if (payload != null) {
|
||||
connection.outputStream.use { it.write(payload.toString().toByteArray(Charsets.UTF_8)) }
|
||||
}
|
||||
val status = connection.responseCode
|
||||
if (status == HttpURLConnection.HTTP_NO_CONTENT) return null
|
||||
val stream = if (status in 200..299) connection.inputStream else connection.errorStream
|
||||
val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
val json = runCatching { JSONObject(body) }.getOrElse { JSONObject() }
|
||||
if (status !in 200..299) {
|
||||
throw AgentApiException(
|
||||
status = status,
|
||||
code = json.optString("code", "HTTP_$status"),
|
||||
message = json.optString("message", "服务端请求失败"),
|
||||
retryable = json.optBoolean("retryable", status >= 500),
|
||||
)
|
||||
}
|
||||
return json
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.ilapage.goauto.agent.network
|
||||
|
||||
import java.net.URI
|
||||
|
||||
object ServerUrlPolicy {
|
||||
fun normalize(value: String, debug: Boolean): String {
|
||||
val trimmed = value.trim().trimEnd('/')
|
||||
require(trimmed.isNotEmpty()) { "请配置服务端地址" }
|
||||
val uri = runCatching { URI(trimmed) }.getOrElse { throw IllegalArgumentException("服务端地址无效") }
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
require(scheme == "https" || (debug && scheme == "http")) {
|
||||
if (debug) "服务端地址必须使用 HTTP 或 HTTPS" else "正式版本只允许 HTTPS"
|
||||
}
|
||||
require(!uri.host.isNullOrBlank() && uri.userInfo == null && uri.query == null && uri.fragment == null) {
|
||||
"服务端地址必须是完整且不含账号、查询参数或片段的地址"
|
||||
}
|
||||
require(uri.path.isNullOrEmpty() || uri.path == "/") { "服务端地址不能包含路径" }
|
||||
return "${scheme}://${uri.rawAuthority}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import cn.ilapage.goauto.agent.BuildConfig
|
||||
import cn.ilapage.goauto.agent.MainActivity
|
||||
import cn.ilapage.goauto.agent.R
|
||||
import cn.ilapage.goauto.agent.automation.CollectionAssembler
|
||||
import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService
|
||||
import cn.ilapage.goauto.agent.automation.PddLinkLauncher
|
||||
import cn.ilapage.goauto.agent.automation.RuleExecutor
|
||||
import cn.ilapage.goauto.agent.automation.RuleParser
|
||||
import cn.ilapage.goauto.agent.automation.RuleValidationException
|
||||
import cn.ilapage.goauto.agent.identity.SecureDeviceStore
|
||||
import cn.ilapage.goauto.agent.network.AgentApiClient
|
||||
import cn.ilapage.goauto.agent.network.AgentApiException
|
||||
import cn.ilapage.goauto.agent.network.DeviceInfo
|
||||
import cn.ilapage.goauto.agent.network.ServerUrlPolicy
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import java.util.UUID
|
||||
|
||||
class AgentForegroundService : Service() {
|
||||
private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
|
||||
private val taskExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
private val taskMutex = TaskExecutionMutex()
|
||||
private val runningTaskId = AtomicReference<Long?>(null)
|
||||
private val working = AtomicBoolean(false)
|
||||
private val registeredThisProcess = AtomicBoolean(false)
|
||||
private lateinit var identityStore: SecureDeviceStore
|
||||
private lateinit var settingsStore: AgentSettingsStore
|
||||
private lateinit var stateStore: AgentStateStore
|
||||
private lateinit var connectivityManager: ConnectivityManager
|
||||
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
triggerSync()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
identityStore = SecureDeviceStore(this)
|
||||
settingsStore = AgentSettingsStore(this)
|
||||
stateStore = AgentStateStore(this)
|
||||
connectivityManager = getSystemService(ConnectivityManager::class.java)
|
||||
createNotificationChannel()
|
||||
startForeground(NOTIFICATION_ID, notification("正在启动"))
|
||||
registerNetworkCallback()
|
||||
executor.scheduleWithFixedDelay(::triggerSync, 0, HEARTBEAT_SECONDS, TimeUnit.SECONDS)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_RECONNECT) registeredThisProcess.set(false)
|
||||
triggerSync()
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
|
||||
executor.shutdownNow()
|
||||
taskExecutor.shutdownNow()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun triggerSync() {
|
||||
if (!working.compareAndSet(false, true)) return
|
||||
executor.execute {
|
||||
try {
|
||||
synchronizeAgent()
|
||||
} finally {
|
||||
working.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun synchronizeAgent() {
|
||||
try {
|
||||
val serverUrl = ServerUrlPolicy.normalize(settingsStore.serverUrl(), BuildConfig.DEBUG)
|
||||
val api = AgentApiClient(serverUrl)
|
||||
var credentials = identityStore.credentials()
|
||||
stateStore.update("CONNECTING", "正在连接并校验设备身份", credentials?.deviceId ?: 0L, credentials != null)
|
||||
updateNotification("正在连接")
|
||||
|
||||
if (!registeredThisProcess.get()) {
|
||||
val registration = api.register(deviceInfo(), credentials?.token)
|
||||
if (credentials == null) {
|
||||
val issuedToken = registration.deviceToken
|
||||
?: error("注册请求已处理,但未返回新 Token;请联系管理员重新签发")
|
||||
identityStore.saveCredentials(registration.deviceId, issuedToken)
|
||||
credentials = identityStore.credentials() ?: error("设备凭据保存失败")
|
||||
} else {
|
||||
check(registration.deviceId == credentials.deviceId) { "服务端设备身份与本地不一致" }
|
||||
}
|
||||
registeredThisProcess.set(true)
|
||||
}
|
||||
|
||||
val activeCredentials = credentials ?: error("设备尚未取得认证凭据")
|
||||
val heartbeat = api.heartbeat(activeCredentials.token, currentTaskId = runningTaskId.get())
|
||||
check(heartbeat.deviceId == activeCredentials.deviceId) { "心跳返回了不同的设备身份" }
|
||||
stateStore.update(
|
||||
code = if (heartbeat.busy) "BUSY" else "ONLINE",
|
||||
message = if (heartbeat.busy) "设备在线,正在执行任务" else "设备在线空闲",
|
||||
deviceId = activeCredentials.deviceId,
|
||||
tokenStored = true,
|
||||
heartbeat = true,
|
||||
)
|
||||
updateNotification(if (heartbeat.busy) "在线 · 执行中" else "在线 · 空闲")
|
||||
scheduleTask(api, activeCredentials.token)
|
||||
} catch (error: AgentApiException) {
|
||||
val authenticationError = error.code in setOf(
|
||||
"DEVICE_TOKEN_INVALID", "DEVICE_INSTALL_ID_CONFLICT", "DEVICE_DISABLED",
|
||||
)
|
||||
val storedCredentials = runCatching { identityStore.credentials() }.getOrNull()
|
||||
stateStore.update(
|
||||
code = if (authenticationError) "AUTH_ERROR" else "NETWORK_ERROR",
|
||||
message = "${error.code}:${error.message}",
|
||||
deviceId = storedCredentials?.deviceId ?: 0L,
|
||||
tokenStored = storedCredentials != null,
|
||||
)
|
||||
updateNotification(if (authenticationError) "设备认证失败" else "连接失败,等待网络恢复")
|
||||
} catch (error: Exception) {
|
||||
val configured = settingsStore.serverUrl().isNotBlank()
|
||||
val storedCredentials = runCatching { identityStore.credentials() }.getOrNull()
|
||||
stateStore.update(
|
||||
code = if (configured) "ERROR" else "CONFIG_REQUIRED",
|
||||
message = error.message ?: "Agent 运行失败",
|
||||
deviceId = storedCredentials?.deviceId ?: 0L,
|
||||
tokenStored = storedCredentials != null,
|
||||
)
|
||||
updateNotification(if (configured) "运行异常" else "等待配置服务端")
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleTask(api: AgentApiClient, token: String) {
|
||||
if (taskMutex.currentTaskId() != null) return
|
||||
val task = api.nextTask(token) ?: return
|
||||
if (!taskMutex.tryAcquire(task.taskId)) return
|
||||
if (task.status == "running") runningTaskId.set(task.taskId)
|
||||
taskExecutor.execute {
|
||||
try {
|
||||
executeTask(api, task, token)
|
||||
} finally {
|
||||
runningTaskId.compareAndSet(task.taskId, null)
|
||||
taskMutex.release(task.taskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeTask(api: AgentApiClient, initialTask: cn.ilapage.goauto.agent.network.AgentTask, token: String) {
|
||||
val wakeLock = acquireTaskWakeLock()
|
||||
try {
|
||||
executeTaskWhileAwake(api, initialTask, token)
|
||||
} finally {
|
||||
if (wakeLock.isHeld) wakeLock.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeTaskWhileAwake(api: AgentApiClient, initialTask: cn.ilapage.goauto.agent.network.AgentTask, token: String) {
|
||||
var started = initialTask.status == "running"
|
||||
try {
|
||||
val claimed = if (initialTask.status == "pending") {
|
||||
api.claimTask(initialTask.taskId, UUID.randomUUID().toString(), token)
|
||||
} else initialTask
|
||||
val task = if (claimed.status == "pending") {
|
||||
api.startTask(claimed.taskId, UUID.randomUUID().toString(), token).also {
|
||||
started = true
|
||||
runningTaskId.set(claimed.taskId)
|
||||
}
|
||||
} else claimed
|
||||
stateStore.update("BUSY", "正在执行任务 #${task.taskId}", tokenStored = true)
|
||||
updateNotification("执行任务 #${task.taskId}")
|
||||
val accessibility = GoAutoAccessibilityService.instance
|
||||
?: throw TaskFailure("ACCESSIBILITY_NOT_READY", "GoAuto 无障碍采集服务未开启或尚未绑定")
|
||||
if (!PddLinkLauncher(this).open(task.urlSnapshot)) {
|
||||
throw TaskFailure("PDD_LINK_INVALID", "任务中的 PDD 链接无法打开")
|
||||
}
|
||||
val rule = try { RuleParser.parse(task.ruleSnapshot) } catch (error: RuleValidationException) {
|
||||
throw TaskFailure(error.code, error.message ?: "规则快照无效")
|
||||
}
|
||||
val execution = RuleExecutor(accessibility).execute(rule)
|
||||
if (!execution.successful) throw TaskFailure(execution.code, execution.message)
|
||||
val result = CollectionAssembler.assemble(task.goodsIdSnapshot, execution.extracted)
|
||||
api.submitResult(task.taskId, UUID.randomUUID().toString(), result, token)
|
||||
stateStore.update("ONLINE", "任务 #${task.taskId} 已提交:${result.status}", tokenStored = true)
|
||||
} catch (error: TaskFailure) {
|
||||
if (started) failSafely(api, initialTask.taskId, token, error.code, error.message ?: "采集失败")
|
||||
} catch (error: AgentApiException) {
|
||||
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
|
||||
} catch (error: Exception) {
|
||||
if (started) failSafely(api, initialTask.taskId, token, "AGENT_EXECUTION_ERROR", error.message ?: "Android 执行异常")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun acquireTaskWakeLock(): PowerManager.WakeLock =
|
||||
getSystemService(PowerManager::class.java).newWakeLock(
|
||||
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP,
|
||||
"$packageName:collection-task",
|
||||
).apply {
|
||||
setReferenceCounted(false)
|
||||
acquire(TASK_WAKE_LOCK_TIMEOUT_MILLIS)
|
||||
}
|
||||
|
||||
private fun failSafely(api: AgentApiClient, taskId: Long, token: String, code: String, message: String) {
|
||||
runCatching { api.submitFailure(taskId, UUID.randomUUID().toString(), code, message, token) }
|
||||
stateStore.update("TASK_ERROR", "$code:$message", tokenStored = true)
|
||||
updateNotification("任务 #$taskId 失败")
|
||||
}
|
||||
|
||||
private fun deviceInfo(): DeviceInfo = DeviceInfo(
|
||||
installId = identityStore.installId(),
|
||||
name = "${Build.MANUFACTURER}-${Build.MODEL}".take(100),
|
||||
manufacturer = Build.MANUFACTURER.take(100),
|
||||
model = Build.MODEL.take(100),
|
||||
androidVersion = Build.VERSION.RELEASE.take(32),
|
||||
agentVersion = BuildConfig.VERSION_NAME.take(32),
|
||||
pddVersion = installedVersion("com.xunmeng.pinduoduo").take(32),
|
||||
)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun installedVersion(packageName: String): String = runCatching {
|
||||
val info = if (Build.VERSION.SDK_INT >= 33) {
|
||||
packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0))
|
||||
} else {
|
||||
packageManager.getPackageInfo(packageName, 0)
|
||||
}
|
||||
info.versionName ?: "unknown"
|
||||
}.getOrDefault("not_installed")
|
||||
|
||||
private fun registerNetworkCallback() {
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
connectivityManager.registerNetworkCallback(request, networkCallback)
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "Agent 连接与心跳", NotificationManager.IMPORTANCE_LOW),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notification(content: String): Notification {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(this, CHANNEL_ID)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Notification.Builder(this)
|
||||
}
|
||||
return builder
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(content)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun updateNotification(content: String) {
|
||||
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(content))
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_RECONNECT = "cn.ilapage.goauto.agent.RECONNECT"
|
||||
private const val CHANNEL_ID = "agent_connection"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val HEARTBEAT_SECONDS = 15L
|
||||
private const val TASK_WAKE_LOCK_TIMEOUT_MILLIS = 5 * 60 * 1000L
|
||||
|
||||
fun start(context: Context, reconnect: Boolean = false) {
|
||||
val intent = Intent(context, AgentForegroundService::class.java).apply {
|
||||
if (reconnect) action = ACTION_RECONNECT
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class TaskFailure(val code: String, message: String) : Exception(message)
|
||||
@@ -0,0 +1,62 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import android.content.Context
|
||||
import cn.ilapage.goauto.agent.BuildConfig
|
||||
import cn.ilapage.goauto.agent.network.ServerUrlPolicy
|
||||
|
||||
data class AgentState(
|
||||
val code: String,
|
||||
val message: String,
|
||||
val deviceId: Long,
|
||||
val lastHeartbeatAt: Long,
|
||||
val tokenStored: Boolean,
|
||||
)
|
||||
|
||||
class AgentSettingsStore(context: Context) {
|
||||
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
|
||||
fun serverUrl(): String = preferences.getString(SERVER_URL, null)
|
||||
?: BuildConfig.DEFAULT_SERVER_URL.takeIf { it.isNotBlank() }.orEmpty()
|
||||
|
||||
fun saveServerUrl(value: String): String {
|
||||
val normalized = ServerUrlPolicy.normalize(value, BuildConfig.DEBUG)
|
||||
check(preferences.edit().putString(SERVER_URL, normalized).commit()) { "无法保存服务端地址" }
|
||||
return normalized
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFERENCES = "goauto_agent_settings"
|
||||
const val SERVER_URL = "server_url"
|
||||
}
|
||||
}
|
||||
|
||||
class AgentStateStore(context: Context) {
|
||||
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
|
||||
fun update(code: String, message: String, deviceId: Long = 0L, tokenStored: Boolean = false, heartbeat: Boolean = false) {
|
||||
val editor = preferences.edit()
|
||||
.putString(STATE_CODE, code)
|
||||
.putString(STATE_MESSAGE, message)
|
||||
.putLong(DEVICE_ID, deviceId)
|
||||
.putBoolean(TOKEN_STORED, tokenStored)
|
||||
if (heartbeat) editor.putLong(LAST_HEARTBEAT, System.currentTimeMillis())
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
fun read(): AgentState = AgentState(
|
||||
code = preferences.getString(STATE_CODE, "STOPPED") ?: "STOPPED",
|
||||
message = preferences.getString(STATE_MESSAGE, "前台服务尚未启动") ?: "前台服务尚未启动",
|
||||
deviceId = preferences.getLong(DEVICE_ID, 0L),
|
||||
lastHeartbeatAt = preferences.getLong(LAST_HEARTBEAT, 0L),
|
||||
tokenStored = preferences.getBoolean(TOKEN_STORED, false),
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val PREFERENCES = "goauto_agent_runtime"
|
||||
const val STATE_CODE = "state_code"
|
||||
const val STATE_MESSAGE = "state_message"
|
||||
const val DEVICE_ID = "device_id"
|
||||
const val LAST_HEARTBEAT = "last_heartbeat"
|
||||
const val TOKEN_STORED = "token_stored"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/** Process-local guard that prevents two collection tasks from executing together. */
|
||||
class TaskExecutionMutex {
|
||||
private val activeTask = AtomicReference<Long?>(null)
|
||||
|
||||
fun tryAcquire(taskId: Long): Boolean {
|
||||
require(taskId > 0L) { "taskId must be positive" }
|
||||
return activeTask.compareAndSet(null, taskId) || activeTask.get() == taskId
|
||||
}
|
||||
|
||||
fun currentTaskId(): Long? = activeTask.get()
|
||||
|
||||
fun release(taskId: Long): Boolean = activeTask.compareAndSet(taskId, null)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">Mobile Agent</string>
|
||||
<string name="accessibility_service_name">GoAuto 采集服务</string>
|
||||
<string name="accessibility_service_description">按管理端下发的规则读取拼多多商品页面并执行采集操作,不保存完整控件树。</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources>
|
||||
<style name="Theme.GoAutoAgent" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:colorAccent">#22C55E</item>
|
||||
<item name="android:navigationBarColor">#020617</item>
|
||||
<item name="android:statusBarColor">#020617</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:accessibilityFlags="flagReportViewIds|flagIncludeNotImportantViews|flagRetrieveInteractiveWindows"
|
||||
android:canRetrieveWindowContent="true"
|
||||
android:description="@string/accessibility_service_description"
|
||||
android:notificationTimeout="100" />
|
||||
@@ -0,0 +1,49 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.CollectionAssembler
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CollectionAssemblerTest {
|
||||
@Test
|
||||
fun `expands every color and size using color price`() {
|
||||
val payload = CollectionAssembler.assemble(
|
||||
"12345",
|
||||
mapOf(
|
||||
"title" to listOf("测试商品"),
|
||||
"shopName" to listOf("测试店铺"),
|
||||
"salesText" to listOf("已拼1.2万件"),
|
||||
"reviewCount" to listOf("64条评价"),
|
||||
"color" to listOf("黑色", "白色"),
|
||||
"size" to listOf("M", "L"),
|
||||
"colorPrice" to listOf("黑色|¥10.00", "白色|12.50"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("completed", payload.status)
|
||||
assertEquals(4, payload.skus.size)
|
||||
assertEquals(1000, payload.skus.first { it.specs["color"] == "黑色" }.priceCent)
|
||||
assertEquals(1250, payload.skus.first { it.specs["color"] == "白色" }.priceCent)
|
||||
assertEquals(64L, payload.product.reviewCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps incomplete result and marks it partial`() {
|
||||
val payload = CollectionAssembler.assemble(
|
||||
"12345",
|
||||
mapOf("title" to listOf("测试商品"), "color" to listOf("黑色", "白色"), "price" to listOf("¥9.99")),
|
||||
)
|
||||
|
||||
assertEquals("completed_partial", payload.status)
|
||||
assertEquals(1, payload.skus.size)
|
||||
assertTrue(payload.missing.contains("price:白色"))
|
||||
assertTrue(payload.missing.contains("size"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses localized count and decimal price`() {
|
||||
assertEquals(12_000L, CollectionAssembler.parseCount("已拼1.2万件"))
|
||||
assertEquals(1_235L, CollectionAssembler.parsePrice("¥12.345"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.PddPageClassifier
|
||||
import cn.ilapage.goauto.agent.automation.RuleParser
|
||||
import cn.ilapage.goauto.agent.automation.RuleValidationException
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class PddNavigationTest {
|
||||
@Test
|
||||
fun `classifies login captcha risk and invalid link`() {
|
||||
assertEquals("PDD_LOGIN_REQUIRED", PddPageClassifier.classify(PDD, null, listOf("手机号登录", "登录后继续"))?.code)
|
||||
assertEquals("PDD_LOGIN_REQUIRED", PddPageClassifier.classify(PDD, "com.xunmeng.pinduoduo.login.LoginActivity", emptyList())?.code)
|
||||
assertEquals("PDD_CAPTCHA_REQUIRED", PddPageClassifier.classify(PDD, null, listOf("请完成滑动验证"))?.code)
|
||||
assertEquals("PDD_RISK_CONTROL", PddPageClassifier.classify(PDD, null, listOf("访问过于频繁"))?.code)
|
||||
assertEquals("PDD_LINK_INVALID", PddPageClassifier.classify(PDD, null, listOf("商品已下架"))?.code)
|
||||
assertNull(PddPageClassifier.classify(PDD, "com.xunmeng.pinduoduo.activity.NewPageActivity", listOf("已拼100件", "商品详情")))
|
||||
assertNull(PddPageClassifier.classify("com.heytap.browser", null, listOf("网页安全验证说明")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `browser click only permits explicit open action`() {
|
||||
val allowed = RuleParser.parse(
|
||||
"""{"schemaVersion":1,"packageName":"com.heytap.browser","steps":[{"id":"open-pdd","action":"click","selector":{"text":"打开拼多多APP"}}]}""",
|
||||
)
|
||||
assertEquals(1, allowed.steps.size)
|
||||
|
||||
val error = runCatching {
|
||||
RuleParser.parse(
|
||||
"""{"schemaVersion":1,"packageName":"com.heytap.browser","steps":[{"id":"other","action":"click","selector":{"text":"允许通知"}}]}""",
|
||||
)
|
||||
}.exceptionOrNull() as RuleValidationException
|
||||
assertEquals("RULE_ACTION_NOT_ALLOWED", error.code)
|
||||
}
|
||||
|
||||
companion object { private const val PDD = "com.xunmeng.pinduoduo" }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.NodeSelector
|
||||
import cn.ilapage.goauto.agent.automation.RuleExecutor
|
||||
import cn.ilapage.goauto.agent.automation.RuleParser
|
||||
import cn.ilapage.goauto.agent.automation.RuleValidationException
|
||||
import cn.ilapage.goauto.agent.automation.UiDriver
|
||||
import cn.ilapage.goauto.agent.automation.UiNodeRef
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class RuleExecutorTest {
|
||||
@Test
|
||||
fun `parses allowlisted rule and extracts value`() {
|
||||
val rule = RuleParser.parse(
|
||||
"""{"schemaVersion":1,"packageName":"com.xunmeng.pinduoduo","steps":[{"id":"title","page":"detail","action":"extract","selector":{"resourceId":"title"},"field":"title"}]}""",
|
||||
)
|
||||
val driver = FakeDriver(mapOf("title" to listOf(UiNodeRef("node-1", "测试商品"))))
|
||||
|
||||
val result = RuleExecutor(driver).execute(rule)
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertEquals(listOf("测试商品"), result.extracted["title"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects ambiguous selector instead of guessing`() {
|
||||
val rule = RuleParser.parse(
|
||||
"""{"schemaVersion":1,"packageName":"com.xunmeng.pinduoduo","steps":[{"id":"open","action":"click","selector":{"text":"打开"}}]}""",
|
||||
)
|
||||
val driver = FakeDriver(mapOf("打开" to listOf(UiNodeRef(1, "打开"), UiNodeRef(2, "打开"))))
|
||||
|
||||
val result = RuleExecutor(driver).execute(rule)
|
||||
|
||||
assertFalse(result.successful)
|
||||
assertEquals("RULE_AMBIGUOUS", result.code)
|
||||
assertEquals(0, driver.clickCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects payment operation at parse time`() {
|
||||
val error = runCatching {
|
||||
RuleParser.parse(
|
||||
"""{"schemaVersion":1,"packageName":"com.xunmeng.pinduoduo","steps":[{"id":"pay","action":"click","selector":{"text":"立即支付"}}]}""",
|
||||
)
|
||||
}.exceptionOrNull() as RuleValidationException
|
||||
|
||||
assertEquals("FORBIDDEN_ACTION", error.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `optional step continues after timeout`() {
|
||||
val rule = RuleParser.parse(
|
||||
"""{"schemaVersion":1,"packageName":"com.xunmeng.pinduoduo","steps":[{"id":"optional","action":"wait","selector":{"text":"不存在"},"timeoutMs":100,"optional":true},{"id":"title","action":"extract","selector":{"resourceId":"title"},"field":"title"}]}""",
|
||||
)
|
||||
var currentTime = 0L
|
||||
val driver = FakeDriver(mapOf("title" to listOf(UiNodeRef("node-1", "测试商品"))))
|
||||
val result = RuleExecutor(driver, now = { currentTime }, pause = { currentTime += it }).execute(rule)
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertEquals(listOf("测试商品"), result.extracted["title"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requires exact activity evidence when rule declares it`() {
|
||||
val rule = RuleParser.parse(
|
||||
"""{"schemaVersion":1,"packageName":"com.xunmeng.pinduoduo","steps":[{"id":"detail","activityName":"com.xunmeng.pinduoduo.activity.NewPageActivity","action":"wait","selector":{"resourceId":"content"},"timeoutMs":100}]}""",
|
||||
)
|
||||
var currentTime = 0L
|
||||
val driver = FakeDriver(
|
||||
mapOf("content" to listOf(UiNodeRef("node-1", null))),
|
||||
activity = "com.xunmeng.pinduoduo.activity.OtherActivity",
|
||||
)
|
||||
|
||||
val result = RuleExecutor(driver, now = { currentTime }, pause = { currentTime += it }).execute(rule)
|
||||
|
||||
assertFalse(result.successful)
|
||||
assertEquals("RULE_NOT_MATCHED", result.code)
|
||||
}
|
||||
|
||||
private class FakeDriver(
|
||||
private val nodes: Map<String, List<UiNodeRef>>,
|
||||
private val activity: String = "com.xunmeng.pinduoduo.activity.NewPageActivity",
|
||||
) : UiDriver {
|
||||
var clickCount = 0
|
||||
override fun currentPackage() = "com.xunmeng.pinduoduo"
|
||||
override fun currentActivity() = activity
|
||||
override fun visibleTexts() = emptyList<String>()
|
||||
override fun find(selector: NodeSelector) = nodes[selector.resourceId ?: selector.text].orEmpty()
|
||||
override fun click(node: UiNodeRef): Boolean { clickCount += 1; return true }
|
||||
override fun input(node: UiNodeRef, value: String) = true
|
||||
override fun back() = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.network.ServerUrlPolicy
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
||||
class ServerUrlPolicyTest {
|
||||
@Test
|
||||
fun normalizesHttpsOrigin() {
|
||||
assertEquals("https://example.com:8443", ServerUrlPolicy.normalize(" https://example.com:8443/ ", false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun releaseRejectsPlainHttp() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
ServerUrlPolicy.normalize("http://192.0.2.10:8000", false)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun debugAllowsPlainHttpForLanTesting() {
|
||||
assertEquals("http://192.0.2.10:8000", ServerUrlPolicy.normalize("http://192.0.2.10:8000", true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsCredentialsPathQueryAndFragment() {
|
||||
listOf(
|
||||
"https://user:pass@example.com",
|
||||
"https://example.com/api",
|
||||
"https://example.com?token=value",
|
||||
"https://example.com#fragment",
|
||||
).forEach { value ->
|
||||
assertThrows(value, IllegalArgumentException::class.java) {
|
||||
ServerUrlPolicy.normalize(value, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class SkeletonTest {
|
||||
@Test
|
||||
fun packageBaselineIsStable() {
|
||||
assertEquals("cn.ilapage.goauto.agent", MainActivity::class.java.packageName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.service.TaskExecutionMutex
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TaskExecutionMutexTest {
|
||||
@Test
|
||||
fun permitsOnlyOneTaskAndMakesSameTaskAcquireIdempotent() {
|
||||
val mutex = TaskExecutionMutex()
|
||||
|
||||
assertTrue(mutex.tryAcquire(10L))
|
||||
assertTrue(mutex.tryAcquire(10L))
|
||||
assertFalse(mutex.tryAcquire(11L))
|
||||
assertEquals(10L, mutex.currentTaskId())
|
||||
assertFalse(mutex.release(11L))
|
||||
assertTrue(mutex.release(10L))
|
||||
assertNull(mutex.currentTaskId())
|
||||
assertTrue(mutex.tryAcquire(11L))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.2.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
|
||||
distributionSha256Sum=38f66cd6eef217b4c35855bb11ea4e9fbc53594ccccb5fb82dfd317ef8c2c5a3
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
Vendored
+248
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m" "-Xss512k" "-XX:+UseSerialGC" "-XX:MaxMetaspaceSize=64m" "-XX:CompressedClassSpaceSize=32m" "-XX:ReservedCodeCacheSize=32m" "-XX:CICompilerCount=2"'
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
# double quotes to make sure that they get re-expanded; and
|
||||
# * put everything else in single quotes, so that it's not re-expanded.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+92
@@ -0,0 +1,92 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" "-Xss512k" "-XX:+UseSerialGC" "-XX:MaxMetaspaceSize=64m" "-XX:CompressedClassSpaceSize=32m" "-XX:ReservedCodeCacheSize=32m" "-XX:CICompilerCount=2"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "GoAutoAgent"
|
||||
include(":app")
|
||||
@@ -0,0 +1,6 @@
|
||||
database:
|
||||
host: 127.0.0.1
|
||||
port: 3307
|
||||
user: root
|
||||
password: ""
|
||||
name: goauto
|
||||
@@ -0,0 +1,227 @@
|
||||
# Design System Master File
|
||||
|
||||
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
|
||||
> If that file exists, its rules **override** this Master file.
|
||||
> If not, strictly follow the rules below.
|
||||
|
||||
---
|
||||
|
||||
**Project:** PDD Mobile Operations
|
||||
**Generated:** 2026-08-14 16:35:42
|
||||
**Category:** Smart Home/IoT Dashboard
|
||||
**Design Dials:** Variance 3/10 (Centered / Minimal) | Motion 3/10 (Subtle) | Density 8/10 (Dense / Dashboard)
|
||||
|
||||
---
|
||||
|
||||
## Global Rules
|
||||
|
||||
### Color Palette
|
||||
|
||||
| Role | Hex | CSS Variable |
|
||||
|------|-----|--------------|
|
||||
| Primary | `#0F172A` | `--color-primary` |
|
||||
| On Primary | `#FFFFFF` | `--color-on-primary` |
|
||||
| Secondary | `#1E293B` | `--color-secondary` |
|
||||
| Accent/CTA | `#22C55E` | `--color-accent` |
|
||||
| Background | `#020617` | `--color-background` |
|
||||
| Foreground | `#F8FAFC` | `--color-foreground` |
|
||||
| Muted | `#1A1E2F` | `--color-muted` |
|
||||
| Border | `#334155` | `--color-border` |
|
||||
| Destructive | `#EF4444` | `--color-destructive` |
|
||||
| Ring | `#0F172A` | `--color-ring` |
|
||||
|
||||
**Color Notes:** Dark bg + green positive indicators
|
||||
|
||||
### Typography
|
||||
|
||||
- **Heading Font:** Fira Code
|
||||
- **Body Font:** Fira Sans
|
||||
- **Mood:** dashboard, data, analytics, code, technical, precise
|
||||
- **Google Fonts:** [Fira Code + Fira Sans](https://fonts.google.com/share?selection.family=Fira+Code:wght@400;500;600;700|Fira+Sans:wght@300;400;500;600;700)
|
||||
|
||||
**CSS Import:**
|
||||
```css
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Fira+Sans:wght@300;400;500;600;700&display=swap');
|
||||
```
|
||||
|
||||
### Spacing Variables
|
||||
|
||||
*Density: 8/10 — Dense / Dashboard*
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--space-xs` | `2px` / `0.125rem` | Tight gaps |
|
||||
| `--space-sm` | `4px` / `0.25rem` | Icon gaps, inline spacing |
|
||||
| `--space-md` | `8px` / `0.5rem` | Standard padding |
|
||||
| `--space-lg` | `12px` / `0.75rem` | Section padding |
|
||||
| `--space-xl` | `16px` / `1rem` | Large gaps |
|
||||
| `--space-2xl` | `24px` / `1.5rem` | Section margins |
|
||||
| `--space-3xl` | `32px` / `2rem` | Hero padding |
|
||||
|
||||
### Shadow Depths
|
||||
|
||||
| Level | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
|
||||
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
|
||||
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
|
||||
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
|
||||
|
||||
---
|
||||
|
||||
## Component Specs
|
||||
|
||||
### Buttons
|
||||
|
||||
```css
|
||||
/* Primary Button */
|
||||
.btn-primary {
|
||||
background: #22C55E;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Secondary Button */
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: #0F172A;
|
||||
border: 2px solid #0F172A;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
```
|
||||
|
||||
### Cards
|
||||
|
||||
```css
|
||||
.card {
|
||||
background: #020617;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
```
|
||||
|
||||
### Inputs
|
||||
|
||||
```css
|
||||
.input {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 200ms ease;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: #0F172A;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px #0F172A20;
|
||||
}
|
||||
```
|
||||
|
||||
### Modals
|
||||
|
||||
```css
|
||||
.modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-xl);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
**Style:** Exaggerated Minimalism
|
||||
|
||||
**Keywords:** Bold minimalism, oversized typography, high contrast, negative space, loud minimal, statement design
|
||||
|
||||
**Best For:** Fashion, architecture, portfolios, agency landing pages, luxury brands, editorial
|
||||
|
||||
**Key Effects:** font-size: clamp(3rem 10vw 12rem), font-weight: 900, letter-spacing: -0.05em, massive whitespace
|
||||
|
||||
### Page Pattern
|
||||
|
||||
**Pattern Name:** Real-Time / Operations Landing
|
||||
|
||||
- **Conversion Strategy:** For ops/security/iot products. Demo or sandbox link. Trust signals.
|
||||
- **CTA Placement:** Primary CTA in nav + After metrics
|
||||
- **Section Order:** 1. Hero (product + live preview or status), 2. Key metrics/indicators, 3. How it works, 4. CTA (Start trial / Contact)
|
||||
|
||||
---
|
||||
|
||||
## Motion
|
||||
|
||||
**Page Transition** (Subtle) — Trigger: route change | Duration: 200-300ms | Easing: `power1.inOut`
|
||||
|
||||
```js
|
||||
gsap.to(main, { opacity: 0, duration: 0.2, onComplete: () => { navigate(); gsap.fromTo(main, { opacity: 0 }, { opacity: 1, duration: 0.2 }); } });
|
||||
```
|
||||
|
||||
**Framework notes:** Pair with the router's transition hooks (Next.js App Router transitions, React Router's useNavigate, Vue Router's beforeEach/afterEach)
|
||||
|
||||
- ✅ Preload the destination route's critical assets before the exit tween finishes
|
||||
- ❌ Don't block navigation on animation; cap exit duration at ~250ms so the app never feels unresponsive
|
||||
- ⚡ Exit animation should always resolve faster than entrance (asymmetric timing) so back/forward feels snappy
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns (Do NOT Use)
|
||||
|
||||
- ❌ Slow updates
|
||||
- ❌ No automation
|
||||
|
||||
### Additional Forbidden Patterns
|
||||
|
||||
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
|
||||
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
|
||||
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
|
||||
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
|
||||
- ❌ **Instant state changes** — Always use transitions (150-300ms)
|
||||
- ❌ **Invisible focus states** — Focus states must be visible for a11y
|
||||
|
||||
---
|
||||
|
||||
## Pre-Delivery Checklist
|
||||
|
||||
Before delivering any UI code, verify:
|
||||
|
||||
- [ ] No emojis used as icons (use SVG instead)
|
||||
- [ ] All icons from consistent icon set (Heroicons/Lucide)
|
||||
- [ ] `cursor-pointer` on all clickable elements
|
||||
- [ ] Hover states with smooth transitions (150-300ms)
|
||||
- [ ] Light mode: text contrast 4.5:1 minimum
|
||||
- [ ] Focus states visible for keyboard navigation
|
||||
- [ ] `prefers-reduced-motion` respected
|
||||
- [ ] Responsive: 375px, 768px, 1024px, 1440px
|
||||
- [ ] No content hidden behind fixed navbars
|
||||
- [ ] No horizontal scroll on mobile
|
||||
@@ -0,0 +1,35 @@
|
||||
# 项目档案
|
||||
|
||||
## 基本信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
|---|---|
|
||||
| 项目名称 | GoAuto 移动采集管理平台 |
|
||||
| 一句话目标 | 管理 Android 手机并从 PDD App 采集商品与规格数据 |
|
||||
| 主要使用者 | 管理员、开发维护者 |
|
||||
| Gitea 仓库 | `OPC/goauto` |
|
||||
| 默认分支 | `main` |
|
||||
| 当前 MVP | PDD 商品、规则、任务、Android 执行、任务详情结果 |
|
||||
| 预计规模 | 20 台 Android;每天约 100 个采集任务 |
|
||||
|
||||
## 交付单元
|
||||
|
||||
| 目录 | 职责 | 技术栈 | 独立验证 |
|
||||
|---|---|---|---|
|
||||
| `server/` | go-admin API、任务调度、规则、设备连接、结果持久化 | Go 1.26.5、go-admin v2.3.0、关系数据库 | Go 单元与构建验证 |
|
||||
| `web/` | go-admin-ui 管理端 | Vue、go-admin-ui v3.0.0、pnpm | lint、生产构建、浏览器验收 |
|
||||
| `android/` | Portal/Agent、注册保活、任务执行和结果提交 | Kotlin 1.9.22、Android SDK 34 | Android 单元测试、APK 构建与真机验收 |
|
||||
| `prototypes/` | 服务端和 Android 交互原型 | HTML/CSS/JavaScript | 静态检查、浏览器人工验收 |
|
||||
| `docs/` | Harness Coding 和共享契约 | Markdown | 链接与结构检查 |
|
||||
|
||||
## 环境与凭据
|
||||
|
||||
- 服务端正式环境必须使用 HTTPS。
|
||||
- 每台设备使用独立 Device Token;Token 只存安全配置,不进入仓库。
|
||||
- PDD 账号密码、Cookie、验证码和用户个人数据不得进入日志。
|
||||
- 根目录 `gitea.env` 是本机工单访问配置,已被 Git 忽略。
|
||||
- `demo/` 是旧 PoC 的本地归档,已被 Git 忽略,不是新产品代码入口。
|
||||
|
||||
## 当前阶段
|
||||
|
||||
T01~T18 已完成当前 MVP 范围内的实现与验证,其中 T01~T05、T18 已由用户验收;T06~T17 保持待验收。T17 已在一加/ColorOS 真机完成指定设备领取、空闲领取、PDD 商品详情页到达和部分结果提交验证,不包含华为兼容。当前范围仍只包含从人工添加 PDD URL 到任务详情查看结果的最小闭环,任何相邻业务必须另建 MVP 并重新确认。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 开发工作流
|
||||
|
||||
## 事实来源
|
||||
|
||||
- Gitea Epic/MVP:目标、阶段与子工单索引。
|
||||
- Gitea 单元工单:需求、讨论、状态、验证和验收过程。
|
||||
- `docs/`:当前架构、业务规则、运行方式和共享接口。
|
||||
- Git:源码、迁移、版本绑定文档和原型。
|
||||
|
||||
## 任务层级
|
||||
|
||||
```text
|
||||
[Epic] Android PDD 商品采集平台
|
||||
└── [MVP] 第一阶段最小采集闭环
|
||||
├── 单元任务:项目骨架
|
||||
├── 单元任务:设备连接
|
||||
├── 单元任务:规则管理
|
||||
└── 单元任务:采集任务与结果
|
||||
```
|
||||
|
||||
单元任务是唯一实施单位。创建工单不代表立即实施;前置依赖满足后才能进入进行中。
|
||||
|
||||
## 状态
|
||||
|
||||
```text
|
||||
待确认 → 待实施 → 进行中 → 待验收 → 已完成
|
||||
└→ 阻塞
|
||||
```
|
||||
|
||||
## 单元任务闭环
|
||||
|
||||
1. 读取工单、项目档案、业务规则和受影响契约。
|
||||
2. 检查前置工单、分支和工作区。
|
||||
3. 严格按范围实现,不混入相邻需求。
|
||||
4. 执行对应单元测试、契约测试和必要真机验证。
|
||||
5. 更新受影响文档并把结果回写工单。
|
||||
6. 提交代码,工单保持待验收。
|
||||
7. 用户明确验收后关闭,并同步父工单。
|
||||
|
||||
## 高风险门禁
|
||||
|
||||
数据库迁移、设备认证、并发租约、自动提交订单、权限和不可逆动作必须单独建单并等待确认。任何自动支付需求直接拒绝实施。
|
||||
@@ -0,0 +1,77 @@
|
||||
# 架构与代码地图
|
||||
|
||||
## MVP 架构
|
||||
|
||||
```text
|
||||
go-admin-ui
|
||||
│ REST:PDD商品、规则、任务、任务详情
|
||||
▼
|
||||
go-admin Server ───────── 数据库
|
||||
│ ├─ pdd_product
|
||||
│ REST:注册/心跳/任务 ├─ collection_rule
|
||||
│ ├─ collection_task(任务+结果)
|
||||
▼ └─ dimension/value/color_price/sku 子表
|
||||
Android Portal/Agent
|
||||
├─ 注册、心跳与设备串行锁
|
||||
├─ 领取指定任务或空闲领取未指定任务
|
||||
├─ 执行任务内的 URL 与规则快照
|
||||
└─ 提交结构化结果或明确错误
|
||||
```
|
||||
|
||||
## 最小闭环
|
||||
|
||||
1. 管理员添加 PDD URL,服务端规范化 URL 并提取唯一 `goods_id`。
|
||||
2. 管理员创建规则;规则创建后立即可用于创建任务。
|
||||
3. 管理员从一个 PDD 商品创建任务,可指定设备,也可留空等待空闲设备领取。
|
||||
4. 任务固化 URL、goods_id 和完整规则快照。
|
||||
5. Android 串行执行并把结果写回同一条任务;规格与 SKU 写入任务结果子表。
|
||||
6. 管理员只在任务详情查看结果,并可对终态任务执行重置。
|
||||
|
||||
## 关键设计
|
||||
|
||||
- `pdd_product.goods_id` 唯一;重复时提示商品已存在,不重复新增。
|
||||
- 规则没有草稿、发布和版本流程;软删除只阻止创建新任务。
|
||||
- 已有任务不依赖规则当前状态,始终执行自身 `rule_snapshot`。
|
||||
- `collection_task` 同时保存任务生命周期和结果摘要,不建立 `collection_result` 主表。
|
||||
- 同一 PDD 商品最多存在一个 `pending` 或 `running` 任务。
|
||||
- 指定设备只能由该设备领取;未指定任务由在线空闲设备原子领取。
|
||||
- 重置在事务中清空原结果,保留 URL、goods_id、规则和设备快照,状态恢复为 `pending`。
|
||||
- Android 本地互斥与服务端原子领取共同保证单设备串行。
|
||||
- 原始控件树和截图不持久化;第一期不使用 OCR/VLM。
|
||||
|
||||
## 最小业务数据
|
||||
|
||||
| 表 | 必要内容 |
|
||||
|---|---|
|
||||
| `agent_device` | 唯一 `install_id`、设备信息、状态、Token 摘要、最后心跳 |
|
||||
| `pdd_product` | `id`、唯一 `goods_id`、当前 `url`、创建/更新时间 |
|
||||
| `collection_rule` | `id`、`name`、`content_json`、创建/更新时间、`deleted_at` |
|
||||
| `collection_task` | 商品/设备外键、五态状态、URL/goods_id/规则快照、租约、结果摘要、错误和时间 |
|
||||
| `collection_dimension` | 任务、维度键、名称、排序 |
|
||||
| `collection_dimension_value` | 维度、值、排序 |
|
||||
| `collection_color_price` | 任务、颜色、该颜色统一使用的整数分价格 |
|
||||
| `collection_sku` | 任务、整数分价格、可用性、完整性 |
|
||||
| `collection_sku_value` | SKU 与规格值的多对多关联 |
|
||||
|
||||
`collection_task` 的状态仅为 `pending`、`running`、`completed`、`completed_partial`、`failed`。设备身份和心跳表属于 Agent 领取任务的必要基础,不承载 PDD 业务数据。
|
||||
|
||||
数据库使用两个可空 guard 列表达跨数据库唯一约束:活动任务的 `active_slot=1`,运行中设备的 `device_run_slot=1`;终态记录对应列为 `NULL`。复合唯一索引据此保证同商品最多一个活动任务、同设备最多一个运行中任务,同时允许保留任意数量的终态历史任务。状态与 guard 列还有数据库检查约束,必须在同一条状态变更语句中更新。
|
||||
|
||||
## 已建立的工程入口
|
||||
|
||||
| 功能 | 当前目录 |
|
||||
|---|---|
|
||||
| 服务端基线 | `server/`(go-admin v2.3.0) |
|
||||
| 最小闭环数据模型 | `server/app/goauto/models/` |
|
||||
| 数据迁移与约束测试 | `server/app/goauto/migrations/` |
|
||||
| go-admin 迁移注册 | `server/cmd/migrate/migration/version-local/1786700000000_goauto_schema.go` |
|
||||
| 设备注册、认证、停用与吊销 | `server/app/goauto/device/` |
|
||||
| PDD 商品与规则 | `server/app/goauto/product/`、`server/app/goauto/rule/` |
|
||||
| 任务领取、结果、重置与删除 | `server/app/goauto/task/` |
|
||||
| 管理端基线 | `web/`(go-admin-ui v3.0.0) |
|
||||
| 管理端闭环页面 | `web/src/views/goauto/` |
|
||||
| Android Agent 基线 | `android/app/src/main/java/cn/ilapage/goauto/agent/` |
|
||||
| Android 无障碍规则执行 | `android/app/src/main/java/cn/ilapage/goauto/agent/automation/` |
|
||||
| 三端统一验证 | `scripts/verify.ps1` |
|
||||
|
||||
以上入口均已落地;新增 API、表或执行动作时必须同步更新本代码地图与共享契约。
|
||||
@@ -0,0 +1,70 @@
|
||||
# 业务规则与术语
|
||||
|
||||
## 当前范围
|
||||
|
||||
当前 MVP 只有四个业务动作:添加 PDD 商品、创建规则、创建采集任务、在任务详情查看 Android 回传结果。顺云宝、Shopee、采购、批量任务、规则发布流程、全局停机和实时屏幕不在范围内。
|
||||
|
||||
## PDD 商品
|
||||
|
||||
- PDD URL 由管理员人工录入。
|
||||
- 服务端必须规范化 URL 并提取 `goods_id`;提取失败时拒绝保存。
|
||||
- `goods_id` 唯一;相同 `goods_id` 再次添加时提示“商品已存在”。
|
||||
- 已存在商品允许修改当前 URL,但不自动改变已创建任务的 URL 快照。
|
||||
|
||||
## 采集规则
|
||||
|
||||
- 规则创建后立即可用,不存在草稿、发布、版本或停用流程。
|
||||
- 规则允许编辑;新任务读取编辑后的当前内容。
|
||||
- 创建任务时保存完整 `rule_snapshot`,以后编辑规则不影响该任务。
|
||||
- 删除采用软删除;删除后不能创建新任务,但已有任务继续执行自身快照。
|
||||
- 商品详情步骤应声明精确 `activityName`,并与包名和唯一控件共同作为页面证据;进入 PDD 登录 Activity 必须返回 `PDD_LOGIN_REQUIRED`,不能提交采集成功。
|
||||
- 唯一文字节点不可点击时,Agent 只可点击其最近的可点击父容器;不得改点兄弟节点或相似文字。
|
||||
|
||||
## 采集任务
|
||||
|
||||
- 任务与结果共用 `collection_task` 主表,不建立独立 `collection_result` 主表。
|
||||
- 创建任务时固化 `url_snapshot`、`goods_id_snapshot` 和 `rule_snapshot`。
|
||||
- 设备默认人工指定;未指定时由在线空闲设备原子领取。
|
||||
- 一台设备同时只执行一个任务。
|
||||
- 同一 PDD 商品不能同时存在多个 `pending` 或 `running` 任务。
|
||||
- 手机离线时运行中任务失败;默认不自动重试、不自动换机。
|
||||
- 失败后允许人工重置或删除后重新创建。
|
||||
- `completed`、`completed_partial`、`failed` 可以重置;`running` 禁止重置和删除。
|
||||
- 重置保留原 URL、goods_id、规则和设备快照,在一个事务中删除旧规格/SKU、清空结果和错误,并恢复为 `pending`。
|
||||
- 任务结果只在任务详情查看,不回填到 PDD 商品列表或独立结果页面。
|
||||
|
||||
## 采集字段
|
||||
|
||||
- PDD 标题、店铺名、销量、评价数量。
|
||||
- 通用规格维度和值,当前重点是颜色和尺码。
|
||||
- 采集时按颜色读取价格;同一颜色下不同尺码默认价格相同。
|
||||
- 存储时展开 SKU 组合,将颜色价格复制到该颜色各尺码 SKU。
|
||||
- SKU 不完整仍提交已有结果并标记 `completed_partial`。
|
||||
|
||||
## 自动化边界
|
||||
|
||||
- 浏览器“打开拼多多APP”和系统确认框“打开”属于允许动作。
|
||||
- 登录失效、验证码、风控、人机验证和找不到唯一控件时失败并给出具体错误。
|
||||
- 不使用 OCR/VLM 兜底,不猜测缺失数据,不保存原始控件树或截图。
|
||||
- 当前 MVP 不创建订单、不支付、不提供实时屏幕或管理端远程控制。
|
||||
|
||||
## 设备身份与认证
|
||||
|
||||
- Android Agent 首次安装生成唯一 `install_id`;服务端收到合法设备信息即注册或更新设备,不使用硬件标识作为唯一键。
|
||||
- `install_id` 在设备表中唯一,不存在注册码、注册申请或设备审核记录。
|
||||
- Device Token 明文只返回给客户端,服务端数据库只保存不可逆摘要。
|
||||
- 设备状态只保存在线、离线或停用;是否忙碌由运行中的任务关系确定。
|
||||
- Agent 默认每 15 秒心跳一次,45 秒没有心跳即离线;离线扫描每 15 秒执行。
|
||||
- 心跳上报的 `currentTaskId` 必须与服务端该设备唯一运行任务一致,否则拒绝更新心跳。
|
||||
- 设备超时离线时,其运行任务立即以 `DEVICE_OFFLINE` 失败,清除租约且不自动重试。
|
||||
|
||||
## 术语
|
||||
|
||||
| 术语 | 含义 |
|
||||
|---|---|
|
||||
| 规则快照 | 创建任务时复制到任务中的完整规则内容 |
|
||||
| URL 快照 | 创建任务时复制到任务中的 PDD URL 和 goods_id |
|
||||
| 空闲领取 | 未指定设备的待执行任务由在线且无活动任务的设备原子领取 |
|
||||
| 部分完成 | 结果已提交,但一个或多个规格/SKU 缺失 |
|
||||
| 重置任务 | 保留任务输入快照,清除原结果后重新进入待执行状态 |
|
||||
| Portal/Agent | Android 端注册、保活、执行规则和提交结果的程序 |
|
||||
@@ -0,0 +1,97 @@
|
||||
# 本地开发与验证
|
||||
|
||||
T01 已建立可执行的三端骨架。建议从仓库根目录运行统一脚本:
|
||||
|
||||
```powershell
|
||||
.\scripts\verify.ps1 -Component all
|
||||
```
|
||||
|
||||
`Component` 也可以是 `server`、`web` 或 `android`。
|
||||
|
||||
## 通用检查
|
||||
|
||||
```powershell
|
||||
git status --short --branch
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## 服务端验证
|
||||
|
||||
```powershell
|
||||
Set-Location server
|
||||
go test ./...
|
||||
go build ./...
|
||||
```
|
||||
|
||||
服务端固定为 go-admin v2.3.0,模块要求 Go 1.26.5;本机可由 `GOTOOLCHAIN=auto` 获取匹配工具链。对象存储用例带有 `integration` 标签,默认验证不会访问云端凭据。
|
||||
|
||||
默认运行目标是 MySQL。开发者在仓库根目录的 `config.yaml` 中维护本机连接参数;该文件已被 Git 忽略,不得提交。可从不含凭据的 `config.example.yaml` 复制。启动脚本读取配置后,仅通过当前进程的 `GOAUTO_DB_DSN` 和 `GOAUTO_DB_DRIVER` 注入服务,不把账号密码写回 `server/config/settings.yml`、日志、工单或文档。
|
||||
|
||||
已经执行过旧版 GoAuto 表结构的数据库,会由 `1786700300000_collection_execution.go` 增量补齐任务幂等、结果、软删除和颜色价格字段;不要通过修改已执行迁移的版本号强制重跑。
|
||||
|
||||
Windows 本地 MySQL 8.4 可以从仓库根目录双击或执行:
|
||||
|
||||
```powershell
|
||||
.\start-server.bat
|
||||
```
|
||||
|
||||
首次使用先编辑根目录 `config.yaml` 的 `database` 节点,配置 `host`、`port`、`user`、`password` 和 `name`。随后脚本直接读取该文件,创建数据库、执行迁移并启动服务,不再交互询问密码。迁移已经执行过时,可使用 `.\start-server.bat -SkipMigration`;只验证配置可使用 `.\start-server.bat -ValidateConfigOnly`。
|
||||
|
||||
管理端前端可单独双击 `start-web.bat`,访问 `http://127.0.0.1:9527`;也可双击 `start-all.bat` 同时打开服务端和前端两个窗口。`http://127.0.0.1:8000` 是 API 服务,根路径显示 go-admin 默认欢迎页属于正常现象。
|
||||
|
||||
只验证最小闭环迁移和数据库约束:
|
||||
|
||||
```powershell
|
||||
Set-Location server
|
||||
go test ./app/goauto/...
|
||||
```
|
||||
|
||||
该命令同时覆盖设备注册的首次签发、requestId 重放、错误 Token、防接管、停用/吊销、HTTPS、限流、心跳任务一致性和超时离线失败测试。
|
||||
|
||||
如需用临时 SQLite 做隔离联调,服务端命令必须显式带上 SQLite 构建标签:
|
||||
|
||||
```powershell
|
||||
Set-Location server
|
||||
go run -tags sqlite3 . migrate -c config/settings.sqlite.yml
|
||||
go run -tags sqlite3 . server -c config/settings.sqlite.yml
|
||||
```
|
||||
|
||||
SQLite 只用于测试;正式运行和最终迁移目标仍为 MySQL 8.4。真实 MySQL 连接串继续通过 `GOAUTO_DB_DSN` 注入。
|
||||
|
||||
## Web 验证
|
||||
|
||||
```powershell
|
||||
Set-Location web
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run lint
|
||||
pnpm run build:prod
|
||||
```
|
||||
|
||||
上游现存 lint 警告和构建体积提示不会阻断验证,但新增代码不得增加错误。
|
||||
|
||||
## Android 验证
|
||||
|
||||
```powershell
|
||||
Set-Location android
|
||||
.\gradlew.bat test
|
||||
.\gradlew.bat assembleDebug
|
||||
```
|
||||
|
||||
Android 骨架使用 Kotlin 1.9.22、AGP 8.2.0、Java 17 和 SDK 34。
|
||||
|
||||
局域网真机联调可在构建时设置 `GOAUTO_SERVER_URL`。只有 Debug 清单允许明文 HTTP;Release 的 URL 校验和 Android 网络安全策略都保持 HTTPS 约束。
|
||||
|
||||
## 原型验证
|
||||
|
||||
- 直接打开 `prototypes/server-admin.html` 和 `prototypes/android-agent.html`。
|
||||
- 检查 375、768、1024、1440 像素宽度。
|
||||
- 用键盘完成导航,检查明显焦点、表单标签和删除/重置确认。
|
||||
- 开启 `prefers-reduced-motion` 后不应依赖动画表达状态。
|
||||
|
||||
## 必须真机验证的范围
|
||||
|
||||
- 一加/ColorOS 的无障碍绑定、后台运行和安装保护。
|
||||
- 任务执行期间 Agent 只持有最长 5 分钟的屏幕唤醒锁,并在任务结束时释放;不解锁安全锁屏,也不常驻保持屏幕。
|
||||
- 浏览器打开 PDD、两层确认、商品详情识别和规格遍历。
|
||||
- 网络断开、登录失效、验证码、风控和规则删除后的快照执行。
|
||||
- 一台设备串行任务和 20 台设备连接稳定性。
|
||||
@@ -0,0 +1,25 @@
|
||||
# 常见修改
|
||||
|
||||
## 增加设备字段
|
||||
|
||||
先检查设备表、注册响应、心跳、管理端列表和 API 文档。跨三端字段必须在同一工单修改并做契约测试。
|
||||
|
||||
## 修改采集字段
|
||||
|
||||
依次检查规则输出、Android 结果 DTO、服务端校验、结果表和管理端详情。不能只改数据库或前端。
|
||||
|
||||
## 新增或修改规则
|
||||
|
||||
规则创建后立即可用。编辑或删除规则不修改已有任务的 `rule_snapshot`;规则软删除后不能创建新任务,已有任务仍可执行。
|
||||
|
||||
## 增加错误码
|
||||
|
||||
错误码必须包含稳定代码、用户可读消息、是否可重试和建议处理。同步更新 Android、服务端、管理端和 `docs/08-agent-api-contract.md`。
|
||||
|
||||
## 修改任务状态
|
||||
|
||||
任务状态影响数据库、领取租约、Android 本地状态和 UI 筛选,属于跨端高风险修改,必须有迁移与并发测试。
|
||||
|
||||
## 修改任务重置
|
||||
|
||||
重置只允许用于终态任务,并在一个事务中删除旧规格/SKU、清空结果与错误、恢复为 `pending`。URL、goods_id、规则和设备快照保持不变。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 故障排查
|
||||
|
||||
## 设备显示离线
|
||||
|
||||
1. 检查服务器 API 地址和 HTTPS 证书。
|
||||
2. 检查 Device Token 是否属于该设备且未吊销。
|
||||
3. 检查 Android 网络、电池限制、自启动和 Portal 进程。
|
||||
4. 对比服务端 `last_seen_at` 与最近心跳失败原因。
|
||||
|
||||
## 一加通过 ADB 安装一直不返回
|
||||
|
||||
ColorOS 开启“安装增强防护”时,电脑端未知来源 APK 可能不会直接失败,而是让 `adb install` 一直等待系统确认:
|
||||
|
||||
1. 保持手机解锁,查看系统弹出的 Mobile Agent 安装详情页。
|
||||
2. 点右上角“更多” → “开始深度扫描”。
|
||||
3. 查看扫描结论后点“继续安装”。
|
||||
4. 更新同一签名 APK 通常可直接执行;首次安装仍以手机实际提示为准。
|
||||
|
||||
不要为方便联调长期关闭系统安装防护,也不要用脚本修改系统安全策略。若只是局域网 Debug 联调,确认安装的是 Debug APK;Release 仍只连接 HTTPS 服务端。
|
||||
|
||||
## Debug 局域网地址提示 Cleartext HTTP 不允许
|
||||
|
||||
- 确认使用包含 `src/debug/AndroidManifest.xml` 的最新 Debug APK,而不是 Release APK。
|
||||
- 重新运行 `assembleDebug` 并覆盖安装;应用诊断页会显示具体错误。
|
||||
- 正式部署不要放开明文 HTTP,应为服务端配置可信 HTTPS 证书。
|
||||
|
||||
## 无法打开 PDD
|
||||
|
||||
1. 检查 Portal 无障碍是否 Enabled 且 Bound。
|
||||
2. 一加检查后台运行、电池优化和浏览器跳转权限。
|
||||
3. 检查浏览器“打开拼多多APP”和系统“打开”是否被最新控件树唯一识别。
|
||||
4. 动作后验证包名和 Activity,不只检查 RPC success。
|
||||
|
||||
## 控件树数据不全
|
||||
|
||||
- 在系统设置页与 PDD 页分别对照。
|
||||
- 一加/ColorOS 若只剩根节点,先重启设备验证 UiAutomation 状态。
|
||||
- 不通过放宽业务选择器掩盖系统抓树故障。
|
||||
|
||||
## 任务一直运行
|
||||
|
||||
1. 检查设备租约是否过期。
|
||||
2. 检查 Android 本地是否仍持有执行锁。
|
||||
3. 检查最后心跳、步骤名、任务规则快照和错误码。
|
||||
4. 网络断开应由超时器把任务置为失败,不能静默回到待领取。
|
||||
|
||||
## 采集结果不完整
|
||||
|
||||
保存已有结果并标记 `completed_partial`;展示缺失维度和值。不要伪造缺失 SKU,也不要自动重试。
|
||||
|
||||
## 规则疑似失效
|
||||
|
||||
复制现有规则创建或编辑可用规则,用测试任务验证。删除旧规则只阻止创建新任务,已经创建的任务仍按自身快照执行。
|
||||
@@ -0,0 +1,58 @@
|
||||
# 第一阶段最小采集闭环
|
||||
|
||||
## 用户闭环
|
||||
|
||||
1. 管理员添加一个 PDD URL。
|
||||
2. 服务端提取 `goods_id`;重复时提示商品已存在。
|
||||
3. 管理员创建一条采集规则,创建成功后立即可用。
|
||||
4. 管理员从 PDD 商品创建采集任务,选择规则,默认指定设备,也可不指定。
|
||||
5. 指定设备领取自己的任务;空闲设备可原子领取未指定任务。
|
||||
6. Android 使用任务内的 URL 和规则快照进入 PDD 并采集结构化数据。
|
||||
7. Android 把完整、部分或失败结果写回该任务。
|
||||
8. 管理员在任务详情查看结果,必要时重置终态任务重新采集。
|
||||
|
||||
## MVP 包含
|
||||
|
||||
- PDD 商品新增、列表和 URL 编辑。
|
||||
- URL 规范化、`goods_id` 提取和唯一性校验。
|
||||
- 规则新增、编辑、列表和软删除;创建即生效。
|
||||
- 单商品创建任务,可指定设备或留空。
|
||||
- 设备注册、令牌、心跳、在线状态与单设备串行。
|
||||
- 指定任务领取与未指定任务的空闲设备原子领取。
|
||||
- 任务内 URL、goods_id 和规则快照。
|
||||
- `pending`、`running`、`completed`、`completed_partial`、`failed` 状态。
|
||||
- 标题、店铺、销量、评价、规格维度、规格值和 SKU 结果。
|
||||
- 终态任务重置、失败任务删除和任务详情。
|
||||
- 登录失效、验证码、风控、人机验证、控件缺失和离线错误。
|
||||
|
||||
## MVP 不包含
|
||||
|
||||
- 顺云宝、货运单和 Shopee 商品。
|
||||
- 采购、下单和支付。
|
||||
- PDD 与其他平台商品关联。
|
||||
- 批量创建任务。
|
||||
- 规则草稿、发布、版本历史和全局停机。
|
||||
- 独立采集结果主表或独立结果页面。
|
||||
- 自动重试、自动换机和离线续跑。
|
||||
- 实时屏幕和管理端远程控制。
|
||||
- OCR/VLM、原始控件树、截图和价格历史。
|
||||
|
||||
## 数据约束
|
||||
|
||||
- `pdd_product.goods_id` 唯一。
|
||||
- 同一 `pdd_product_id` 最多一个 `pending` 或 `running` 任务。
|
||||
- 一台设备最多一个 `running` 任务。
|
||||
- 规则软删除后不能创建新任务,但已有任务不受影响。
|
||||
- 终态任务提交后结果冻结,除非管理员执行重置。
|
||||
- 重置事务保留所有输入快照,删除全部旧结果子记录并清空结果字段。
|
||||
|
||||
## 验收
|
||||
|
||||
- 一加/ColorOS 完成真实商品闭环;当前 MVP 不包含华为兼容。
|
||||
- 重复 goods_id 显示明确冲突,不产生第二条商品。
|
||||
- 规则创建后可以直接选用;删除后不能创建新任务,旧任务仍可执行。
|
||||
- 指定设备和未指定设备两种领取方式均正确且没有双领。
|
||||
- 同一商品不能创建第二个未完成任务。
|
||||
- 每个颜色价格正确展开到该颜色的尺码 SKU;部分结果正常展示。
|
||||
- 重置后原结果彻底清除,快照保持不变,并可重新采集。
|
||||
- 离线和各类安全页面返回明确错误且不自动重试。
|
||||
@@ -0,0 +1,236 @@
|
||||
# MVP 共享 API 契约
|
||||
|
||||
本文件是服务端、Web 和 Android 的共享契约事实来源。字段可以在实施工单中补充,但语义变化必须同步更新本文档。
|
||||
|
||||
## 通用约定
|
||||
|
||||
- 正式环境仅允许 HTTPS。
|
||||
- 每台设备使用独立 Bearer Token。
|
||||
- 时间使用 RFC 3339 UTC,金额使用整数分。
|
||||
- 所有写接口携带 `requestId` 并保证幂等。
|
||||
- 错误响应包含稳定的 `code`、可读的 `message` 和 `retryable`。
|
||||
|
||||
## 管理端:PDD 商品
|
||||
|
||||
```http
|
||||
POST /api/admin/v1/pdd-products
|
||||
PATCH /api/admin/v1/pdd-products/{productId}
|
||||
GET /api/admin/v1/pdd-products
|
||||
```
|
||||
|
||||
新增请求只提交 `url`。服务端展开并规范化 URL、提取 `goods_id`;无法提取时返回 `PDD_GOODS_ID_INVALID`,已存在时返回 `PDD_PRODUCT_EXISTS` 和现有商品 ID。编辑 URL 不改变已有任务快照。
|
||||
|
||||
## 管理端:采集规则
|
||||
|
||||
```http
|
||||
POST /api/admin/v1/collection-rules
|
||||
PATCH /api/admin/v1/collection-rules/{ruleId}
|
||||
DELETE /api/admin/v1/collection-rules/{ruleId}
|
||||
GET /api/admin/v1/collection-rules
|
||||
```
|
||||
|
||||
规则只有 `name` 和 `content` 等当前值,创建成功后立即可用。删除为软删除;已删除规则不能创建新任务,已有任务继续使用任务内快照。
|
||||
|
||||
`content` 使用以下 `schemaVersion: 1` 契约:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"packageName": "com.xunmeng.pinduoduo",
|
||||
"steps": [
|
||||
{
|
||||
"id": "extract-title",
|
||||
"page": "product-detail",
|
||||
"activityName": "com.xunmeng.pinduoduo.activity.NewPageActivity",
|
||||
"action": "extract",
|
||||
"selector": {"resourceId": "com.xunmeng.pinduoduo:id/title"},
|
||||
"field": "title",
|
||||
"timeoutMs": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `action` 只允许 `wait`、`click`、`input`、`back`、`extract`;除 `back` 外都必须有非空 `selector`。
|
||||
- `selector` 可组合 `resourceId`、`text`、`contentDescription`、`className`、`clickable`,执行操作前必须唯一命中;不唯一时以 `RULE_AMBIGUOUS` 失败,不猜测目标。
|
||||
- 点击步骤仍以唯一命中的节点作为语义证据;若文字节点本身不可点击,只允许沿其父链点击最近的 `clickable` 容器,不搜索兄弟节点或相近候选。父链不存在可点击容器时以 `RULE_ACTION_FAILED` 失败。
|
||||
- 每一步可以用 `packageName` 覆盖根级默认值;只允许拼多多、受支持浏览器和 Android 系统选择器包。
|
||||
- `activityName` 可声明该步骤允许执行的精确 Activity;商品详情页规则应同时校验 PDD 包名、目标 Activity 和唯一控件,避免登录页等同包页面误判为商品页。
|
||||
- `input` 必须有 `value`,`extract` 必须有 `field`;单步 `timeoutMs` 范围为 100~30000 毫秒。
|
||||
- `optional: true` 仅表示单步超时后继续,适合不同 ROM 可能不存在的浏览器/系统“打开”确认层;多匹配和动作执行失败仍立即失败。
|
||||
- 规则解析阶段拒绝支付、付款、提交订单等危险目标。完整控件树只在 Android 内存中用于匹配,不保存也不上传。
|
||||
- 浏览器和 Android 系统包中的 `click` 只允许精确目标 `打开拼多多APP`、`打开拼多多 App` 或 `打开`;其它点击在规则解析阶段拒绝。Android 先以浏览器打开服务端已规范化的 PDD URL,再由这些受控步骤进入拼多多。
|
||||
|
||||
## 管理端:采集任务
|
||||
|
||||
```http
|
||||
POST /api/admin/v1/collection-tasks
|
||||
GET /api/admin/v1/collection-tasks
|
||||
GET /api/admin/v1/collection-tasks/{taskId}
|
||||
POST /api/admin/v1/collection-tasks/{taskId}/reset
|
||||
DELETE /api/admin/v1/collection-tasks/{taskId}
|
||||
```
|
||||
|
||||
创建请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "uuid",
|
||||
"pddProductId": "product-id",
|
||||
"ruleId": "rule-id",
|
||||
"deviceId": "device-id-or-null"
|
||||
}
|
||||
```
|
||||
|
||||
服务端在事务中复制当前 `url`、`goods_id` 和完整规则内容到任务。同一商品已有 `pending` 或 `running` 任务时返回 `PDD_PRODUCT_TASK_ACTIVE`。
|
||||
|
||||
重置只允许 `completed`、`completed_partial` 或 `failed`:
|
||||
|
||||
- 保留 `pddProductId`、`deviceId`、`urlSnapshot`、`goodsIdSnapshot`、`ruleId` 和 `ruleSnapshot`。
|
||||
- 删除全部规格维度、规格值、SKU 和 SKU 值关联。
|
||||
- 清空标题、店铺、销量、评价、错误与执行时间。
|
||||
- 状态恢复为 `pending`。
|
||||
|
||||
`running` 禁止重置和删除。失败任务允许删除后重新创建。
|
||||
|
||||
`reset` 请求体和失败任务 `DELETE` 请求体均为 `{"requestId":"uuid"}`。只允许删除 `failed`;重置允许 `completed`、`completed_partial`、`failed`。二者均按 `requestId` 幂等。
|
||||
|
||||
## Android:注册与心跳
|
||||
|
||||
```http
|
||||
POST /api/agent/v1/register
|
||||
POST /api/agent/v1/heartbeat
|
||||
```
|
||||
|
||||
客户端首次启动生成并持久化 `installId`,直接提交设备信息;服务端收到合法请求即创建或更新设备,不要求注册码、后台审核或人工确认。`installId` 在服务端唯一,硬件标识不能作为替代唯一键。响应返回 `deviceId`、Device Token 和心跳间隔,客户端安全保存 Token,服务端只保存其不可逆摘要。后续用 `installId` 保持同一安装实例的设备身份;心跳上报设备状态和 `currentTaskId`,服务端据此判断在线/空闲,但任务领取仍以数据库原子约束为准。
|
||||
|
||||
注册请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "uuid",
|
||||
"installId": "uuid",
|
||||
"name": "HUAWEI-01",
|
||||
"manufacturer": "HUAWEI",
|
||||
"model": "Mate 60",
|
||||
"androidVersion": "14",
|
||||
"agentVersion": "0.1.0",
|
||||
"pddVersion": "7.72.0"
|
||||
}
|
||||
```
|
||||
|
||||
- 新 `installId` 立即创建设备并返回一次 `deviceToken`;响应带 `Cache-Control: no-store`。
|
||||
- 相同 `requestId` 重放只幂等返回原 `deviceId`,不再次返回 Token,也不覆盖设备信息。
|
||||
- 已存在 `installId` 的新请求必须携带该设备的 Bearer Token,认证成功后幂等更新设备信息,不轮换 Token。
|
||||
- 缺少或使用错误/已吊销 Token 返回 HTTP 409 和 `DEVICE_INSTALL_ID_CONFLICT`。
|
||||
- 生产模式只接受 TLS。只有服务部署在可信反向代理之后并显式设置 `GOAUTO_TRUST_FORWARDED_PROTO=true` 时,服务端才接受代理的 `X-Forwarded-Proto: https`;不得在服务直接暴露公网时开启。注册接口另有单实例、按直连来源 IP 的基础限流,网关仍须设置共享限流。
|
||||
|
||||
管理员设备动作:
|
||||
|
||||
```http
|
||||
POST /api/admin/v1/devices/{deviceId}/disable
|
||||
POST /api/admin/v1/devices/{deviceId}/token/revoke
|
||||
```
|
||||
|
||||
两者都要求 go-admin 管理端认证与角色授权。吊销同时停用设备;重新启用和重新签发 Token 不在本工单范围。
|
||||
|
||||
心跳请求使用设备 Bearer Token:
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "uuid",
|
||||
"currentTaskId": "task-id-or-null"
|
||||
}
|
||||
```
|
||||
|
||||
服务端校验 `currentTaskId` 必须与该设备唯一的 `running` 任务一致,不一致返回 HTTP 409 和 `DEVICE_TASK_MISMATCH`,不更新心跳。响应返回服务端确认的 `currentTaskId`、`online`、由运行任务派生的 `busy`、服务端时间和下一次心跳间隔。相同 `requestId` 重放不推进心跳时间。
|
||||
|
||||
默认心跳间隔 15 秒、离线阈值 45 秒、扫描间隔 15 秒。超过阈值后设备标为 `offline`;其 `running` 任务同时清除租约和活动 guard,转为 `failed`,错误码为 `DEVICE_OFFLINE`,且不自动重试或换机。设备重新发送合法心跳后可以恢复 `online`,但失败任务不会自动恢复。
|
||||
|
||||
## Android:获取、领取和开始任务
|
||||
|
||||
```http
|
||||
GET /api/agent/v1/tasks/next
|
||||
POST /api/agent/v1/tasks/{taskId}/claim
|
||||
POST /api/agent/v1/tasks/{taskId}/start
|
||||
```
|
||||
|
||||
`claim` 和 `start` 请求体都为 `{"requestId":"uuid"}`。相同 `requestId` 重放返回首次成功后的当前任务载荷,不延长租约、不增加租约版本。`next` 没有可用任务时返回 HTTP 204。
|
||||
|
||||
领取规则:
|
||||
|
||||
1. 指定设备的任务只能由指定设备领取。
|
||||
2. `deviceId` 为空的任务可由在线且没有活动任务的设备领取。
|
||||
3. `claim` 在一个数据库事务中设置设备和租约,竞争失败返回 `TASK_ALREADY_CLAIMED`。
|
||||
4. 设备已有活动任务时返回 `DEVICE_BUSY`。
|
||||
|
||||
`claim` 保持任务为 `pending`,写入设备、两分钟领取租约和递增的 `leaseVersion`;`start` 只接受当前设备持有的有效租约,将状态原子改为 `running`、记录开始时间并续租。Android 进程还必须以本地互斥锁确保同一时刻只有一个任务进入执行器。
|
||||
|
||||
任务载荷包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"taskId": "task-id",
|
||||
"pddProductId": "product-id",
|
||||
"urlSnapshot": "https://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||
"goodsIdSnapshot": "123",
|
||||
"ruleId": "rule-id",
|
||||
"ruleSnapshot": {},
|
||||
"timeoutSeconds": 120
|
||||
}
|
||||
```
|
||||
|
||||
规则已经软删除不影响载荷和执行。
|
||||
|
||||
## Android:提交结果
|
||||
|
||||
```http
|
||||
POST /api/agent/v1/tasks/{taskId}/result
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "uuid",
|
||||
"status": "completed_partial",
|
||||
"product": {
|
||||
"pddGoodsId": "123",
|
||||
"title": "商品标题",
|
||||
"shopName": "店铺名",
|
||||
"salesText": "已拼5872件",
|
||||
"reviewCount": 64
|
||||
},
|
||||
"dimensions": [
|
||||
{"key": "color", "name": "颜色", "values": ["黑色", "白色"]},
|
||||
{"key": "size", "name": "尺码", "values": ["M", "L"]}
|
||||
],
|
||||
"colorPrices": [
|
||||
{"color": "黑色", "priceCent": 1000},
|
||||
{"color": "白色", "priceCent": 1200}
|
||||
],
|
||||
"skus": [
|
||||
{"specs": {"color": "黑色", "size": "M"}, "priceCent": 1000, "available": true}
|
||||
],
|
||||
"missing": ["白色/L"]
|
||||
}
|
||||
```
|
||||
|
||||
服务端验证任务当前设备和状态后,在一个事务中更新 `collection_task` 结果字段并重建结果子表。终态任务拒绝不同内容的再次提交;相同 `requestId` 幂等返回原响应。
|
||||
|
||||
## Android:提交失败
|
||||
|
||||
```http
|
||||
POST /api/agent/v1/tasks/{taskId}/fail
|
||||
```
|
||||
|
||||
| 错误码 | 含义 | 默认重试 |
|
||||
|---|---|---|
|
||||
| `DEVICE_OFFLINE` | 执行中连接中断 | 否 |
|
||||
| `ACCESSIBILITY_NOT_READY` | 无障碍未绑定 | 否 |
|
||||
| `PDD_LOGIN_REQUIRED` | PDD 登录失效 | 否 |
|
||||
| `PDD_CAPTCHA_REQUIRED` | 验证码或人机验证 | 否 |
|
||||
| `PDD_RISK_CONTROL` | 风控页面 | 否 |
|
||||
| `PDD_LINK_INVALID` | 商品链接无效、商品不存在或已下架 | 否 |
|
||||
| `RULE_NOT_MATCHED` | 找不到唯一控件或页面 | 否 |
|
||||
| `RULE_AMBIGUOUS` | 规则同时匹配多个控件 | 否 |
|
||||
| `TASK_ALREADY_CLAIMED` | 未指定任务已被其他设备领取 | 否 |
|
||||
| `DEVICE_BUSY` | 设备已有活动任务 | 否 |
|
||||
@@ -0,0 +1,43 @@
|
||||
# 当前 MVP 交付工单索引
|
||||
|
||||
## 层级
|
||||
|
||||
- [Epic #1:PDD 商品采集最小闭环](https://git.ilapage.cn/OPC/goauto/issues/1)
|
||||
- [MVP #2:PDD 商品到 Android 采集结果的最小闭环](https://git.ilapage.cn/OPC/goauto/issues/2)
|
||||
|
||||
## 活动 Task
|
||||
|
||||
| 顺序 | 工单 | 交付项 | 依赖 |
|
||||
|---|---|---|---|
|
||||
| T01 | [#3](https://git.ilapage.cn/OPC/goauto/issues/3) | 三端工程骨架与验证基线 | 无 |
|
||||
| T02 | [#4](https://git.ilapage.cn/OPC/goauto/issues/4) | 最小闭环数据库模型 | T01 |
|
||||
| T03 | [#5](https://git.ilapage.cn/OPC/goauto/issues/5) | 设备自注册、认证与吊销 | T01、T02 |
|
||||
| T04 | [#6](https://git.ilapage.cn/OPC/goauto/issues/6) | 心跳与设备在线空闲状态 | T02、T03 |
|
||||
| T05 | [#7](https://git.ilapage.cn/OPC/goauto/issues/7) | Android 自动注册、心跳与保活 | T01、T03、T04 |
|
||||
| T06 | [#8](https://git.ilapage.cn/OPC/goauto/issues/8) | 指定/空闲领取、租约与串行 | T02~T04 |
|
||||
| T07 | [#9](https://git.ilapage.cn/OPC/goauto/issues/9) | 自注册设备列表与任务设备选择 | T03、T04 |
|
||||
| T09 | [#11](https://git.ilapage.cn/OPC/goauto/issues/11) | PDD 商品与 URL 管理 | T01、T02 |
|
||||
| T10 | [#12](https://git.ilapage.cn/OPC/goauto/issues/12) | 创建即生效的规则管理 | T01、T02 |
|
||||
| T11 | [#13](https://git.ilapage.cn/OPC/goauto/issues/13) | Android 规则快照解释器 | T05、T06、T10 |
|
||||
| T12 | [#14](https://git.ilapage.cn/OPC/goauto/issues/14) | 浏览器到 PDD 跳转与异常 | T11 |
|
||||
| T13 | [#15](https://git.ilapage.cn/OPC/goauto/issues/15) | 商品字段、规格与颜色价格 | T11、T12 |
|
||||
| T14 | [#16](https://git.ilapage.cn/OPC/goauto/issues/16) | 单个 PDD 商品任务列表与创建 | T06、T07、T09、T10 |
|
||||
| T15 | [#17](https://git.ilapage.cn/OPC/goauto/issues/17) | 任务结果持久化与详情 | T02、T06、T13、T14 |
|
||||
| T16 | [#18](https://git.ilapage.cn/OPC/goauto/issues/18) | 任务重置、失败删除与并发约束 | T02、T06、T15 |
|
||||
| T17 | [#19](https://git.ilapage.cn/OPC/goauto/issues/19) | 一加最小闭环验收 | T05、T06、T09~T16 |
|
||||
| T18 | [#20](https://git.ilapage.cn/OPC/goauto/issues/20) | 本地 config.yaml 数据库启动配置 | T01 |
|
||||
|
||||
## 延期
|
||||
|
||||
- [#10:只读实时屏幕](https://git.ilapage.cn/OPC/goauto/issues/10) 已关闭,未实施,不属于当前 MVP。
|
||||
|
||||
## 推荐执行批次
|
||||
|
||||
1. T01 → T02:工程与数据基线。
|
||||
2. T03/T09/T10 → T04:设备身份、PDD 商品和规则。
|
||||
3. T05/T06/T07:Android 连接与任务领取。
|
||||
4. T11 → T12 → T13:Android 采集执行链。
|
||||
5. T14 → T15 → T16:任务创建、详情、重置与删除。
|
||||
6. T17:一加/ColorOS 真机验收。
|
||||
|
||||
任何实现工作必须先把对应活动 Task 置为进行中,并在工单中记录验证证据。
|
||||
@@ -0,0 +1,50 @@
|
||||
# 一加真机验收记录
|
||||
|
||||
## 验收范围
|
||||
|
||||
T17 只验证 PDD 商品采集最小闭环:Agent 注册与心跳、任务串行领取、浏览器进入 PDD、规则快照执行、结构化结果或明确失败、管理端详情与重置。禁止购买和支付,不保存控件树或截图。
|
||||
|
||||
## 2026-08-15 一加 PKG110(Android 16)
|
||||
|
||||
已通过:
|
||||
|
||||
- ADB 设备在线,PDD App 已安装。
|
||||
- Debug Agent 安装成功并保持前台服务。
|
||||
- Agent 使用局域网地址访问服务端,设备自动注册成功,之后每 15 秒持续心跳。
|
||||
- 手机到服务端 8000 端口连通。
|
||||
- Android 单元测试与 Debug APK 构建通过。
|
||||
- 人工启用“GoAuto 采集服务”后,系统显示服务已启用且已绑定;应用升级或被强制停止后,一加系统可能撤销该授权,必须重新人工启用。
|
||||
- 指定设备任务成功经历 `pending`、`running`、`completed_partial`,完成任务领取、PDD 链接打开、规则快照执行和结构化结果提交。
|
||||
- 未指定设备任务由在线空闲的一加设备原子领取,领取后设备快照固定为设备 2,并成功提交 `completed_partial`。
|
||||
- 浏览器页面中的“安全验证”文案不再被误判为 PDD 验证码;登录、验证码和风控识别只在 PDD 包内生效。
|
||||
- 真实商品页可访问节点能够识别商品标题、销量、店铺入口;进入店铺页能够识别店铺名;规格弹层能够识别颜色和尺码。
|
||||
- 当前安全基线规则只验证到达 PDD 商品详情页,因此缺少标题、店铺、销量、评价、颜色和尺码时按约定提交 `completed_partial`,没有猜测数据。
|
||||
- 同一任务重置后,旧错误和结果摘要已清空并可重新领取;URL、goods_id、规则和设备快照保持不变。
|
||||
- 设备心跳中断后,运行中任务按约定以 `DEVICE_OFFLINE` 失败且没有自动重试或换机;重新启用设备后必须人工新建或重置任务。
|
||||
- 全程未点击提交订单或支付控件,未保存控件树或截图。
|
||||
- 最终版在任务执行期间使用有时限的屏幕唤醒锁,防止设备休眠中断心跳;任务终止后立即释放。
|
||||
|
||||
待验证:
|
||||
|
||||
- 用正式字段规则逐项采集标题、店铺、销量、评价、颜色、尺码和各颜色价格,并在管理端详情复核规格与 SKU 展开结果。
|
||||
- 当前只有一台受支持的一加设备,多设备防双领已通过服务端并发测试,尚无第二台一加真机可做补充复核。
|
||||
|
||||
## 2026-08-15 最终闭环证据
|
||||
|
||||
- 任务 16(指定设备):`pending → running → completed_partial`;最终前台包名为 `com.xunmeng.pinduoduo`,Activity 为 `com.xunmeng.pinduoduo.activity.NewPageActivity`。
|
||||
- 任务 17(未指定设备):由设备 2 原子领取并提交 `completed_partial`。
|
||||
- 两条任务均保存 URL、goods_id、规则和设备关联,只保存结构化缺失字段清单;规格、颜色价格和 SKU 子表没有伪造记录。
|
||||
- 本轮未执行购买或支付,也未持久化原始控件树或截图。
|
||||
|
||||
## 支持范围
|
||||
|
||||
当前 MVP 只维护一加/ColorOS 真机兼容,不包含华为 ROM。实机过程中发现的精确 Activity 页面证据和最近可点击父容器规则属于通用安全能力,继续保留。
|
||||
|
||||
## 通过标准
|
||||
|
||||
- 每台设备同一时刻最多运行一个任务,指定任务不能被其它设备领取。
|
||||
- 页面控件唯一匹配;未命中或多命中必须失败,不猜测点击。
|
||||
- 登录、验证码、人机验证、风控和无效链接返回稳定错误码且不自动重试。
|
||||
- 字段不完整仍保存结果并标记 `completed_partial`。
|
||||
- 任务详情可查看规格与 SKU;重置后旧结果和错误全部清空。
|
||||
- 全程不触发购买/支付,不保存原始控件树或截图。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Harness Coding 文档中心
|
||||
|
||||
本文档集面向项目负责人、开发 Agent 和接手维护的程序员。建议按编号顺序阅读。
|
||||
|
||||
| 文档 | 回答的问题 |
|
||||
|---|---|
|
||||
| `00-project-profile.md` | 项目是什么、包含哪些交付单元 |
|
||||
| `01-workflow.md` | 如何从需求、工单走到验收 |
|
||||
| `02-architecture-and-code-map.md` | 请求如何跨服务端、Web 和 Android 流动 |
|
||||
| `03-business-rules-and-glossary.md` | 哪些业务与安全规则不能破坏 |
|
||||
| `04-local-development-and-verification.md` | 如何运行与验证 |
|
||||
| `05-common-changes.md` | 常见修改从哪里开始 |
|
||||
| `06-troubleshooting.md` | 失败时按什么顺序排查 |
|
||||
| `07-mvp-requirements.md` | 第一阶段必须交付什么 |
|
||||
| `08-agent-api-contract.md` | Android 与服务端的共享接口 |
|
||||
| `09-delivery-issues.md` | 一期 Epic、MVP、Task 及依赖索引 |
|
||||
| `10-real-device-acceptance.md` | 一加/ColorOS 真机闭环验收证据与待测项 |
|
||||
|
||||
当前 `docs/` 由 Git 直接维护。后续启用 Gitea Wiki 镜像时,必须通过独立工单迁移,不能形成两个互相冲突的事实来源。
|
||||
@@ -0,0 +1,7 @@
|
||||
# 一期交互原型
|
||||
|
||||
- `index.html`:原型入口与范围说明。
|
||||
- `server-admin.html`:服务端管理网页原型,覆盖 PDD 商品、创建即生效规则、单商品任务、任务详情、重置和自动注册设备管理。
|
||||
- `android-agent.html`:Android Agent 首次启动自动注册、令牌认证、心跳、必要权限、任务快照、执行步骤和日志原型。
|
||||
|
||||
这些文件不依赖 CDN,可直接用浏览器打开。当前原型明确不包含顺云宝、Shopee、采购、批量任务、规则发布流程、实时屏幕或真实 PDD 自动化实现。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>PDD Mobile Operations · 原型入口</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}body{margin:0;background:#020617;color:#e2e8f0;font:15px/1.6 "Segoe UI","Microsoft YaHei",sans-serif}.wrap{max-width:1080px;margin:auto;padding:72px 24px}.eyebrow{color:#4ade80;font-weight:700;letter-spacing:.12em}.hero{max-width:720px}h1{font-size:clamp(38px,6vw,72px);line-height:1.05;margin:12px 0 22px;letter-spacing:-.04em}p{color:#94a3b8}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:20px;margin-top:48px}.card{display:block;padding:28px;border:1px solid #1e293b;border-radius:18px;background:#0f172a;color:inherit;text-decoration:none;transition:.2s}.card:hover,.card:focus-visible{border-color:#22c55e;transform:translateY(-3px);outline:none}.card b{display:block;font-size:21px;margin-bottom:8px}.tag{display:inline-flex;padding:4px 10px;border-radius:99px;background:#14532d;color:#bbf7d0;font-size:12px;font-weight:700}.note{margin-top:30px;padding:18px 20px;border-left:3px solid #22c55e;background:#07130d}.note strong{color:#f8fafc}@media(max-width:700px){.wrap{padding-top:42px}.grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body><main class="wrap"><div class="hero"><div class="eyebrow">INTERACTIVE PROTOTYPE</div><h1>PDD 商品采集闭环</h1><p>添加 PDD 链接、创建规则、创建任务、Android 执行并在任务详情查看结果。</p></div><div class="grid"><a class="card" href="server-admin.html"><span class="tag">DESKTOP</span><b>服务端管理台</b><p>PDD 商品、规则、单商品任务,以及首次上报即登记的设备列表。</p></a><a class="card" href="android-agent.html"><span class="tag">ANDROID</span><b>Android Agent</b><p>首次启动自动注册、令牌认证、心跳保活、任务快照和结果提交。</p></a></div><div class="note"><strong>技术基线:</strong>go-admin v2.3.0、go-admin-ui v3.0.0、Kotlin / Android SDK 34。<br><strong>当前范围:</strong>不包含顺云宝、Shopee、采购、批量任务、规则发布流程或实时屏幕。</div></main></body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,205 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigPath,
|
||||
[string]$DatabaseHost,
|
||||
[Nullable[int]]$DatabasePort,
|
||||
[string]$DatabaseUser,
|
||||
[string]$DatabaseName,
|
||||
[switch]$SkipMigration,
|
||||
[switch]$ValidateConfigOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$workspaceRoot = Split-Path -Parent $PSScriptRoot
|
||||
$serverDirectory = Join-Path $workspaceRoot "server"
|
||||
$migrationLog = Join-Path $serverDirectory "temp\startup-migration.log"
|
||||
$plainPassword = $null
|
||||
|
||||
function ConvertFrom-YamlScalar {
|
||||
param([string]$Value)
|
||||
|
||||
$value = $Value.Trim()
|
||||
if ($value.Length -ge 2) {
|
||||
if ($value.StartsWith('"') -and $value.EndsWith('"')) {
|
||||
return $value.Substring(1, $value.Length - 2).Replace('\"', '"').Replace('\\', '\')
|
||||
}
|
||||
if ($value.StartsWith("'") -and $value.EndsWith("'")) {
|
||||
return $value.Substring(1, $value.Length - 2).Replace("''", "'")
|
||||
}
|
||||
}
|
||||
return $value
|
||||
}
|
||||
|
||||
function Read-DatabaseConfig {
|
||||
param([string]$Path)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||
throw "Local configuration file was not found: $Path. Copy config.example.yaml to config.yaml and fill in database.password."
|
||||
}
|
||||
|
||||
$values = @{}
|
||||
$insideDatabase = $false
|
||||
foreach ($line in Get-Content -LiteralPath $Path) {
|
||||
if ($line -match '^\s*(#.*)?$') {
|
||||
continue
|
||||
}
|
||||
if ($line -match '^database\s*:\s*$') {
|
||||
$insideDatabase = $true
|
||||
continue
|
||||
}
|
||||
if ($insideDatabase -and $line -match '^\S') {
|
||||
break
|
||||
}
|
||||
if ($insideDatabase -and $line -match '^\s+(host|port|user|password|name)\s*:\s*(.*?)\s*$') {
|
||||
$values[$Matches[1]] = ConvertFrom-YamlScalar $Matches[2]
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($key in 'host', 'port', 'user', 'password', 'name') {
|
||||
if (-not $values.ContainsKey($key) -or [string]::IsNullOrWhiteSpace([string]$values[$key])) {
|
||||
throw "Missing database.$key in local configuration file: $Path"
|
||||
}
|
||||
}
|
||||
|
||||
$parsedPort = 0
|
||||
if (-not [int]::TryParse($values.port, [ref]$parsedPort) -or $parsedPort -lt 1 -or $parsedPort -gt 65535) {
|
||||
throw "database.port must be an integer between 1 and 65535 in: $Path"
|
||||
}
|
||||
if ($values.user -notmatch '^[A-Za-z0-9_.-]+$') {
|
||||
throw "database.user contains unsupported characters in: $Path"
|
||||
}
|
||||
if ($values.name -notmatch '^[A-Za-z0-9_]+$') {
|
||||
throw "database.name contains unsupported characters in: $Path"
|
||||
}
|
||||
|
||||
return @{
|
||||
Host = [string]$values.host
|
||||
Port = $parsedPort
|
||||
User = [string]$values.user
|
||||
Password = [string]$values.password
|
||||
Name = [string]$values.name
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-MySqlClient {
|
||||
$services = Get-CimInstance Win32_Service -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -match '^MySQL' -and $_.State -eq 'Running' }
|
||||
foreach ($service in $services) {
|
||||
if ($service.PathName -match '^"?(.*?\\mysqld\.exe)"?(?:\s|$)') {
|
||||
$serviceClient = Join-Path (Split-Path -Parent $Matches[1]) 'mysql.exe'
|
||||
if (Test-Path -LiteralPath $serviceClient) {
|
||||
return $serviceClient
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$defaultPath = "C:\Program Files\MySQL\MySQL Server 8.4\bin\mysql.exe"
|
||||
if (Test-Path -LiteralPath $defaultPath) {
|
||||
return $defaultPath
|
||||
}
|
||||
|
||||
$command = Get-Command mysql.exe -ErrorAction SilentlyContinue
|
||||
if ($command) {
|
||||
return $command.Source
|
||||
}
|
||||
|
||||
throw "mysql.exe was not found. Add the MySQL 8.4 bin directory to PATH."
|
||||
}
|
||||
|
||||
try {
|
||||
if ([string]::IsNullOrWhiteSpace($ConfigPath)) {
|
||||
$ConfigPath = Join-Path $workspaceRoot "config.yaml"
|
||||
}
|
||||
elseif (-not [IO.Path]::IsPathRooted($ConfigPath)) {
|
||||
$ConfigPath = Join-Path $workspaceRoot $ConfigPath
|
||||
}
|
||||
$ConfigPath = [IO.Path]::GetFullPath($ConfigPath)
|
||||
$databaseConfig = Read-DatabaseConfig $ConfigPath
|
||||
|
||||
if (-not $PSBoundParameters.ContainsKey('DatabaseHost')) {
|
||||
$DatabaseHost = $databaseConfig.Host
|
||||
}
|
||||
if (-not $PSBoundParameters.ContainsKey('DatabasePort')) {
|
||||
$DatabasePort = $databaseConfig.Port
|
||||
}
|
||||
if (-not $PSBoundParameters.ContainsKey('DatabaseUser')) {
|
||||
$DatabaseUser = $databaseConfig.User
|
||||
}
|
||||
if (-not $PSBoundParameters.ContainsKey('DatabaseName')) {
|
||||
$DatabaseName = $databaseConfig.Name
|
||||
}
|
||||
$plainPassword = $databaseConfig.Password
|
||||
|
||||
Write-Host "GoAuto local server" -ForegroundColor Cyan
|
||||
Write-Host "Config: $ConfigPath"
|
||||
Write-Host "MySQL: ${DatabaseUser}@${DatabaseHost}:${DatabasePort}/${DatabaseName}"
|
||||
if ($ValidateConfigOnly) {
|
||||
Write-Host "Local database configuration is valid." -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
|
||||
$mysqlClient = Resolve-MySqlClient
|
||||
$env:MYSQL_PWD = $plainPassword
|
||||
try {
|
||||
Write-Host "[1/3] Checking database ${DatabaseName}..." -ForegroundColor Cyan
|
||||
& $mysqlClient `
|
||||
--protocol=TCP `
|
||||
--host=$DatabaseHost `
|
||||
--port=$DatabasePort `
|
||||
--user=$DatabaseUser `
|
||||
--default-character-set=utf8mb4 `
|
||||
--execute="CREATE DATABASE IF NOT EXISTS ``${DatabaseName}`` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "MySQL connection or database creation failed."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item Env:MYSQL_PWD -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$env:GOAUTO_DB_DRIVER = "mysql"
|
||||
$env:GOAUTO_DB_DSN = "${DatabaseUser}:${plainPassword}@tcp(${DatabaseHost}:${DatabasePort})/${DatabaseName}?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s"
|
||||
|
||||
Push-Location $serverDirectory
|
||||
try {
|
||||
if (-not $SkipMigration) {
|
||||
Write-Host "[2/3] Running database migrations..." -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Path (Split-Path -Parent $migrationLog) -Force | Out-Null
|
||||
$previousErrorActionPreference = $ErrorActionPreference
|
||||
try {
|
||||
$ErrorActionPreference = "Continue"
|
||||
& go run . migrate -c config/settings.yml 2>&1 |
|
||||
Tee-Object -FilePath $migrationLog
|
||||
$migrationExitCode = $LASTEXITCODE
|
||||
}
|
||||
finally {
|
||||
$ErrorActionPreference = $previousErrorActionPreference
|
||||
}
|
||||
if ($migrationExitCode -ne 0) {
|
||||
$migrationTail = (Get-Content -LiteralPath $migrationLog -Tail 40 -ErrorAction SilentlyContinue) -join [Environment]::NewLine
|
||||
throw "Database migration failed. Log: ${migrationLog}`n${migrationTail}"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "[2/3] Database migration skipped." -ForegroundColor DarkYellow
|
||||
}
|
||||
|
||||
Write-Host "[3/3] Starting server..." -ForegroundColor Green
|
||||
Write-Host "Local URL: http://127.0.0.1:8000"
|
||||
Write-Host "Android URL: use this computer's LAN IP on port 8000"
|
||||
Write-Host "Press Ctrl+C to stop." -ForegroundColor DarkYellow
|
||||
& go run . server -c config/settings.yml
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Server exited with code $LASTEXITCODE."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item Env:MYSQL_PWD -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOAUTO_DB_DSN -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOAUTO_DB_DRIVER -ErrorAction SilentlyContinue
|
||||
$plainPassword = $null
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
param(
|
||||
[ValidateSet("all", "server", "web", "android")]
|
||||
[string]$Component = "all"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
function Invoke-Step([string]$Name, [scriptblock]$Action) {
|
||||
Write-Host "==> $Name"
|
||||
& $Action
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Name failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
if ($Component -in @("all", "server")) {
|
||||
Push-Location (Join-Path $projectRoot "server")
|
||||
try {
|
||||
$env:GOTOOLCHAIN = "auto"
|
||||
Invoke-Step "server: go test ./..." { go test ./... }
|
||||
Invoke-Step "server: go build" { go build -o (Join-Path $env:TEMP "goauto-server-check.exe") . }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
if ($Component -in @("all", "web")) {
|
||||
Push-Location (Join-Path $projectRoot "web")
|
||||
try {
|
||||
Invoke-Step "web: pnpm install" { pnpm install --frozen-lockfile }
|
||||
Invoke-Step "web: lint" { pnpm run lint }
|
||||
Invoke-Step "web: build" { pnpm run build:prod }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
if ($Component -in @("all", "android")) {
|
||||
Push-Location (Join-Path $projectRoot "android")
|
||||
try {
|
||||
Invoke-Step "android: unit tests" { .\gradlew.bat test }
|
||||
Invoke-Step "android: debug APK" { .\gradlew.bat assembleDebug }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 🆕 Create new issue
|
||||
url: http://new-issue.go-admin.dev
|
||||
about: The issue which is not created via http://new-issue.go-admin.dev will be closed immediately.
|
||||
- name: 🆕 创建一个新 Issue
|
||||
url: http://new-issue.go-admin.dev
|
||||
about: 不是用 http://new-issue.go-admin.dev 创建的 issue 会被机器人自动关闭。
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<!--
|
||||
First of all, thank you for your contribution! 😄
|
||||
|
||||
For requesting to pull a new feature or bugfix, please send it from a feature/bugfix branch based on the `master` branch.
|
||||
|
||||
Before submitting your pull request, please make sure the checklist below is confirmed.
|
||||
|
||||
Your pull requests will be merged after one of the collaborators approve.
|
||||
|
||||
Thank you!
|
||||
|
||||
-->
|
||||
|
||||
[[中文版模板 / Chinese template](https://github.com/go-admin-team/go-admin/blob/master/.github/PULL_REQUEST_TEMPLATE/pr_cn.md)]
|
||||
|
||||
### 🤔 This is a ...
|
||||
|
||||
- [ ] New feature
|
||||
- [ ] Bug fix
|
||||
- [ ] Site / documentation update
|
||||
- [ ] Demo update
|
||||
- [ ] Component style update
|
||||
- [ ] TypeScript definition update
|
||||
- [ ] Bundle size optimization
|
||||
- [ ] Performance optimization
|
||||
- [ ] Enhancement feature
|
||||
- [ ] Internationalization
|
||||
- [ ] Refactoring
|
||||
- [ ] Code style optimization
|
||||
- [ ] Test Case
|
||||
- [ ] Branch merge
|
||||
- [ ] Other (about what?)
|
||||
|
||||
### 🔗 Related issue link
|
||||
|
||||
<!--
|
||||
1. Put the related issue or discussion links here.
|
||||
-->
|
||||
|
||||
### 💡 Background and solution
|
||||
|
||||
<!--
|
||||
1. Describe the problem and the scenario.
|
||||
2. GIF or snapshot should be provided if includes UI/interactive modification.
|
||||
3. How to fix the problem, and list the final API implementation and usage sample if that is a new feature.
|
||||
-->
|
||||
|
||||
### 📝 Changelog
|
||||
|
||||
<!--
|
||||
Describe changes from the user side, and list all potential break changes or other risks.
|
||||
--->
|
||||
|
||||
| Language | Changelog |
|
||||
| ---------- | --------- |
|
||||
| 🇺🇸 English | |
|
||||
| 🇨🇳 Chinese | |
|
||||
|
||||
### ☑️ Self-Check before Merge
|
||||
|
||||
⚠️ Please check all items below before review. ⚠️
|
||||
|
||||
- [ ] Doc is updated/provided or not needed
|
||||
- [ ] Demo is updated/provided or not needed
|
||||
- [ ] TypeScript's definition is updated/provided or not needed
|
||||
- [ ] Changelog is provided or not needed
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
<!--
|
||||
首先,感谢你的贡献!😄
|
||||
|
||||
新特性请提交至 feature 分支,其余可提交至 master 分支。
|
||||
在维护者审核通过后会合并。
|
||||
请确保填写以下 pull request 的信息,谢谢!~
|
||||
-->
|
||||
|
||||
[[English Template / 英文模板](https://github.com/go-admin-team/go-admin/blob/master/.github/PULL_REQUEST_TEMPLATE.md)]
|
||||
|
||||
### 🤔 这个变动的性质是?
|
||||
|
||||
- [ ] 新特性提交
|
||||
- [ ] 日常 bug 修复
|
||||
- [ ] 站点、文档改进
|
||||
- [ ] 演示代码改进
|
||||
- [ ] 组件样式/交互改进
|
||||
- [ ] TypeScript 定义更新
|
||||
- [ ] 包体积优化
|
||||
- [ ] 性能优化
|
||||
- [ ] 功能增强
|
||||
- [ ] 国际化改进
|
||||
- [ ] 重构
|
||||
- [ ] 代码风格优化
|
||||
- [ ] 测试用例
|
||||
- [ ] 分支合并
|
||||
- [ ] 其他改动(是关于什么的改动?)
|
||||
|
||||
### 🔗 相关 Issue
|
||||
|
||||
<!--
|
||||
1. 描述相关需求的来源,如相关的 issue 讨论链接。
|
||||
-->
|
||||
|
||||
### 💡 需求背景和解决方案
|
||||
|
||||
<!--
|
||||
1. 要解决的具体问题。
|
||||
2. 列出最终的 API 实现和用法。
|
||||
3. 涉及UI/交互变动需要有截图或 GIF。
|
||||
-->
|
||||
|
||||
### 📝 更新日志
|
||||
|
||||
<!--
|
||||
从用户角度描述具体变化,以及可能的 breaking change 和其他风险。
|
||||
-->
|
||||
|
||||
| 语言 | 更新描述 |
|
||||
| ------- | -------- |
|
||||
| 🇺🇸 英文 | |
|
||||
| 🇨🇳 中文 | |
|
||||
|
||||
### ☑️ 请求合并前的自查清单
|
||||
|
||||
⚠️ 请自检并全部**勾选全部选项**。⚠️
|
||||
|
||||
- [ ] 文档已补充或无须补充
|
||||
- [ ] 代码演示已提供或无须提供
|
||||
- [ ] TypeScript 定义已补充或无须补充
|
||||
- [ ] Changelog 已提供或无须提供
|
||||
Vendored
+57
@@ -0,0 +1,57 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
env:
|
||||
IMAGE_NAME: registry.ap-northeast-1.aliyuncs.com/go-admin/go-admin-api # 镜像名称
|
||||
TAG: ${{ github.sha }}
|
||||
IMAGE_NAME_TAG: registry.ap-northeast-1.aliyuncs.com/go-admin/go-admin-api:${{ github.sha }}
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: 1.26.5
|
||||
|
||||
- name: Tidy
|
||||
run: go mod tidy
|
||||
|
||||
- name: Build
|
||||
run: env CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -tags "sqlite3,json1" --ldflags "-extldflags -static" -o main .
|
||||
|
||||
- name: Build the Docker image and push
|
||||
run: |
|
||||
docker login --username=${{ secrets.DOCKER_USERNAME }} registry.ap-northeast-1.aliyuncs.com --password=${{ secrets.DOCKER_PASSWORD }}
|
||||
echo "************ docker login end"
|
||||
docker build -t go-admin-api:latest .
|
||||
echo "************ docker build end"
|
||||
docker tag go-admin-api ${{ env.IMAGE_NAME_TAG }}
|
||||
echo "************ docker tag end"
|
||||
docker images
|
||||
echo "************ docker images end"
|
||||
docker push ${{ env.IMAGE_NAME_TAG }} # 推送
|
||||
echo "************ docker push end"
|
||||
|
||||
- name: Restart server # 第五步,重启服务
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
env:
|
||||
GITHUB_SHA_X: ${GITHUB_SHA}
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }} # 下面三个配置与上一步类似
|
||||
username: ${{ secrets.SSH_USERNAME }}
|
||||
key: ${{ secrets.DEPLOY_KEY }}
|
||||
# 重启的脚本,根据自身情况做相应改动,一般要做的是migrate数据库以及重启服务器
|
||||
script: |
|
||||
sudo docker rm -f go-admin-api
|
||||
sudo docker login --username=${{ secrets.DOCKER_USERNAME }} registry.ap-northeast-1.aliyuncs.com --password=${{ secrets.DOCKER_PASSWORD }}
|
||||
sudo docker run -d -p 8000:8000 --name go-admin-api ${{ env.IMAGE_NAME_TAG }}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'go' ]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@24c7eb380a2dc368f2d129e4c65e51d172983a1e # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@24c7eb380a2dc368f2d129e4c65e51d172983a1e # v4
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@24c7eb380a2dc368f2d129e4c65e51d172983a1e # v4
|
||||
Vendored
+67
@@ -0,0 +1,67 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, dev ]
|
||||
tags: [ 'v*', '[0-9]*' ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Set up Go 1.26
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: 1.26.5
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Get dependencies
|
||||
run: go mod tidy
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
flavor: |
|
||||
latest=auto
|
||||
tags: |
|
||||
type=schedule
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=,format=long,enable=true,priority=100
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
context: .
|
||||
file: scripts/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
name: 'GitHub Actions Mirror'
|
||||
|
||||
on: [push, delete]
|
||||
|
||||
jobs:
|
||||
mirror_to_gitee:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: 'Mirror to gitee'
|
||||
uses: pixta-dev/repository-mirroring-action@674e65a7d483ca28dafaacba0d07351bdcc8bd75 # v1
|
||||
with:
|
||||
target_repo_url:
|
||||
git@gitee.com:go-admin-team/go-admin.git
|
||||
ssh_private_key:
|
||||
${{ secrets.GITEE_KEY }}
|
||||
mirror_to_gitlab:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: 'Mirror to gitlab'
|
||||
uses: pixta-dev/repository-mirroring-action@674e65a7d483ca28dafaacba0d07351bdcc8bd75 # v1
|
||||
with:
|
||||
target_repo_url:
|
||||
git@gitlab.com:go-admin-team/go-admin.git
|
||||
ssh_private_key:
|
||||
${{ secrets.GITLAB_KEY }}
|
||||
@@ -0,0 +1,24 @@
|
||||
.idea
|
||||
.vscode
|
||||
*/.DS_Store
|
||||
static/uploadfile
|
||||
main.exe
|
||||
*.exe
|
||||
go-admin
|
||||
go-admin.exe
|
||||
temp/
|
||||
vendor
|
||||
config/settings.dev.yml
|
||||
config/settings.dev.*.yml
|
||||
config/settings.dev.*.yml.log
|
||||
temp/logs
|
||||
config/settings.dev.yml.log
|
||||
config/settings.b.dev.yml
|
||||
cmd/migrate/migration/version-local/*
|
||||
!cmd/migrate/migration/version-local/doc.go
|
||||
|
||||
config/settings.deva.yml
|
||||
go-admin-server
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
config/settings.local.dev.yml
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM alpine
|
||||
|
||||
# ENV GOPROXY https://goproxy.cn/
|
||||
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories
|
||||
|
||||
RUN apk update --no-cache
|
||||
RUN apk add --update gcc g++ libc6-compat
|
||||
RUN apk add --no-cache ca-certificates
|
||||
RUN apk add --no-cache tzdata
|
||||
ENV TZ Asia/Shanghai
|
||||
|
||||
COPY ./main /main
|
||||
COPY ./config/settings.demo.yml /config/settings.yml
|
||||
COPY ./go-admin-db.db /go-admin-db.db
|
||||
EXPOSE 8000
|
||||
RUN chmod +x /main
|
||||
CMD ["/main","server","-c", "/config/settings.yml"]
|
||||
@@ -0,0 +1,28 @@
|
||||
FROM golang:alpine as builder
|
||||
|
||||
MAINTAINER lwnmengjing
|
||||
|
||||
ENV GOPROXY https://goproxy.cn/
|
||||
|
||||
WORKDIR /go/release
|
||||
#RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
RUN apk update && apk add tzdata
|
||||
|
||||
COPY go.mod ./go.mod
|
||||
RUN go mod tidy
|
||||
COPY . .
|
||||
RUN pwd && ls
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -a -installsuffix cgo -o go-admin .
|
||||
|
||||
FROM alpine
|
||||
|
||||
COPY --from=builder /go/release/go-admin /
|
||||
|
||||
COPY --from=builder /go/release/config/settings.yml /config/settings.yml
|
||||
|
||||
COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["/go-admin","server","-c", "/config/settings.yml"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 go-admin-team
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,54 @@
|
||||
PROJECT:=go-admin
|
||||
|
||||
.PHONY: build
|
||||
build:
|
||||
CGO_ENABLED=0 go build -ldflags="-w -s" -a -installsuffix "" -o go-admin .
|
||||
|
||||
# make build-linux
|
||||
build-linux:
|
||||
@docker build -t go-admin:latest .
|
||||
@echo "build successful"
|
||||
|
||||
build-sqlite:
|
||||
go build -tags sqlite3 -ldflags="-w -s" -a -installsuffix -o go-admin .
|
||||
|
||||
# make run
|
||||
run:
|
||||
# delete go-admin-api container
|
||||
@if [ $(shell docker ps -aq --filter name=go-admin --filter publish=8000) ]; then docker rm -f go-admin; fi
|
||||
|
||||
# 启动方法一 run go-admin-api container docker-compose 启动方式
|
||||
# 进入到项目根目录 执行 make run 命令
|
||||
@docker-compose up -d
|
||||
|
||||
# 启动方式二 docker run 这里注意-v挂载的宿主机的地址改为部署时的实际决对路径
|
||||
#@docker run --name=go-admin -p 8000:8000 -v /home/code/go/src/go-admin/go-admin/config:/go-admin-api/config -v /home/code/go/src/go-admin/go-admin-api/static:/go-admin/static -v /home/code/go/src/go-admin/go-admin/temp:/go-admin-api/temp -d --restart=always go-admin:latest
|
||||
|
||||
@echo "go-admin service is running..."
|
||||
|
||||
# delete Tag=<none> 的镜像
|
||||
@docker image prune -f
|
||||
@docker ps -a | grep "go-admin"
|
||||
|
||||
stop:
|
||||
# delete go-admin-api container
|
||||
@if [ $(shell docker ps -aq --filter name=go-admin --filter publish=8000) ]; then docker-compose down; fi
|
||||
#@if [ $(shell docker ps -aq --filter name=go-admin --filter publish=8000) ]; then docker rm -f go-admin; fi
|
||||
#@echo "go-admin stop success"
|
||||
|
||||
|
||||
#.PHONY: test
|
||||
#test:
|
||||
# go test -v ./... -cover
|
||||
|
||||
#.PHONY: docker
|
||||
#docker:
|
||||
# docker build . -t go-admin:latest
|
||||
|
||||
# make deploy
|
||||
deploy:
|
||||
|
||||
#@git checkout master
|
||||
#@git pull origin master
|
||||
make build-linux
|
||||
make run
|
||||
@@ -0,0 +1,355 @@
|
||||
# go-admin
|
||||
|
||||
<img align="right" width="320" src="https://doc-image.zhangwj.com/img/go-admin.svg">
|
||||
|
||||
|
||||
[](https://github.com/go-admin-team/go-admin)
|
||||
[](https://github.com/go-admin-team/go-admin/releases)
|
||||
[](https://github.com/go-admin-team/go-admin)
|
||||
|
||||
[English](https://github.com/go-admin-team/go-admin/blob/master/README.md) | 简体中文
|
||||
|
||||
基于Gin + Vue + Element UI OR Arco Design OR Ant Design的前后端分离权限管理系统,系统初始化极度简单,只需要配置文件中,修改数据库连接,系统支持多指令操作,迁移指令可以让初始化数据库信息变得更简单,服务指令可以很简单的启动api服务
|
||||
|
||||
[在线文档](https://www.go-admin.pro)
|
||||
|
||||
[前端项目](https://github.com/go-admin-team/go-admin-ui)
|
||||
|
||||
[视频教程](https://space.bilibili.com/565616721/channel/detail?cid=125737)
|
||||
|
||||
## 🎬 在线体验
|
||||
|
||||
Element UI vue体验:[https://vue2.go-admin.dev](https://vue2.go-admin.dev/#/login)
|
||||
> ⚠️⚠️⚠️ 账号 / 密码: admin / 123456
|
||||
|
||||
Arco Design vue3 demo:[https://vue3.go-admin.dev](https://vue3.go-admin.dev/#/login)
|
||||
> ⚠️⚠️⚠️ 账号 / 密码: admin / 123456
|
||||
|
||||
antd体验:[https://antd.go-admin.pro](https://antd.go-admin.pro/)
|
||||
> ⚠️⚠️⚠️ 账号 / 密码: admin / 123456
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- 遵循 RESTful API 设计规范
|
||||
|
||||
- 基于 GIN WEB API 框架,提供了丰富的中间件支持(用户认证、跨域、访问日志、追踪ID等)
|
||||
|
||||
- 基于Casbin的 RBAC 访问控制模型
|
||||
|
||||
- JWT 认证
|
||||
|
||||
- 支持 Swagger 文档(基于swaggo)
|
||||
|
||||
- 基于 GORM 的数据库存储,可扩展多种类型数据库
|
||||
|
||||
- 配置文件简单的模型映射,快速能够得到想要的配置
|
||||
|
||||
- 代码生成工具
|
||||
|
||||
- 表单构建工具
|
||||
|
||||
- 多指令模式
|
||||
|
||||
- 多租户的支持
|
||||
|
||||
- TODO: 单元测试
|
||||
|
||||
## 🎁 内置
|
||||
|
||||
1. 多租户:系统默认支持多租户,按库分离,一个库一个租户。
|
||||
1. 用户管理:用户是系统操作者,该功能主要完成系统用户配置。
|
||||
2. 部门管理:配置系统组织机构(公司、部门、小组),树结构展现支持数据权限。
|
||||
3. 岗位管理:配置系统用户所属担任职务。
|
||||
4. 菜单管理:配置系统菜单,操作权限,按钮权限标识,接口权限等。
|
||||
5. 角色管理:角色菜单权限分配、设置角色按机构进行数据范围权限划分。
|
||||
6. 字典管理:对系统中经常使用的一些较为固定的数据进行维护。
|
||||
7. 参数管理:对系统动态配置常用参数。
|
||||
8. 操作日志:系统正常操作日志记录和查询;系统异常信息日志记录和查询。
|
||||
9. 登录日志:系统登录日志记录查询包含登录异常。
|
||||
1. 接口文档:根据业务代码自动生成相关的api接口文档。
|
||||
1. 代码生成:根据数据表结构生成对应的增删改查相对应业务,全程可视化操作,让基本业务可以零代码实现。
|
||||
1. 表单构建:自定义页面样式,拖拉拽实现页面布局。
|
||||
1. 服务监控:查看一些服务器的基本信息。
|
||||
1. 内容管理:demo功能,下设分类管理、内容管理。可以参考使用方便快速入门。
|
||||
1. 定时任务:自动化任务,目前支持接口调用和函数调用。
|
||||
|
||||
## 准备工作
|
||||
|
||||
你需要在本地安装 [go] [gin] [node](http://nodejs.org/) 和 [git](https://git-scm.com/)
|
||||
|
||||
同时配套了系列教程包含视频和文档,如何从下载完成到熟练使用,强烈建议大家先看完这些教程再来实践本项目!!!
|
||||
|
||||
### 轻松实现go-admin写出第一个应用 - 文档教程
|
||||
|
||||
[步骤一 - 基础内容介绍](https://doc.zhangwj.com/guide/intro/tutorial01.html)
|
||||
|
||||
[步骤二 - 实际应用 - 编写增删改查](https://doc.zhangwj.com/guide/intro/tutorial02.html)
|
||||
|
||||
### 手把手教你从入门到放弃 - 视频教程
|
||||
|
||||
[如何启动go-admin](https://www.bilibili.com/video/BV1z5411x7JG)
|
||||
|
||||
[使用生成工具轻松实现业务](https://www.bilibili.com/video/BV1Dg4y1i79D)
|
||||
|
||||
[v1.1.0版本代码生成工具-释放双手](https://www.bilibili.com/video/BV1N54y1i71P) [进阶]
|
||||
|
||||
[多命令启动方式讲解以及IDE配置](https://www.bilibili.com/video/BV1Fg4y1q7ph)
|
||||
|
||||
[go-admin菜单的配置说明](https://www.bilibili.com/video/BV1Wp4y1D715) [必看]
|
||||
|
||||
[如何配置菜单信息以及接口信息](https://www.bilibili.com/video/BV1zv411B7nG) [必看]
|
||||
|
||||
[go-admin权限配置使用说明](https://www.bilibili.com/video/BV1rt4y197d3) [必看]
|
||||
|
||||
[go-admin数据权限使用说明](https://www.bilibili.com/video/BV1LK4y1s71e) [必看]
|
||||
|
||||
**如有问题请先看上述使用文档和文章,若不能满足,欢迎 issue 和 pr ,视频教程和文档持续更新中**
|
||||
|
||||
## 📦 本地开发
|
||||
|
||||
### 环境要求
|
||||
|
||||
go 1.26.5
|
||||
|
||||
node版本: v22+(推荐 v24 LTS)
|
||||
|
||||
包管理器: pnpm v9+(UI 项目使用 pnpm)
|
||||
|
||||
### 开发目录创建
|
||||
|
||||
```bash
|
||||
|
||||
# 创建开发目录
|
||||
mkdir goadmin
|
||||
cd goadmin
|
||||
```
|
||||
|
||||
### 获取代码
|
||||
|
||||
> 重点注意:两个项目必须放在同一文件夹下;
|
||||
|
||||
```bash
|
||||
# 获取后端代码
|
||||
git clone https://github.com/go-admin-team/go-admin.git
|
||||
|
||||
# 获取前端代码
|
||||
git clone https://github.com/go-admin-team/go-admin-ui.git
|
||||
|
||||
```
|
||||
|
||||
### 启动说明
|
||||
|
||||
#### 服务端启动说明
|
||||
|
||||
```bash
|
||||
# 进入 go-admin 后端项目
|
||||
cd ./go-admin
|
||||
|
||||
# 更新整理依赖
|
||||
go mod tidy
|
||||
|
||||
# 编译项目
|
||||
go build
|
||||
|
||||
# 修改配置
|
||||
# 文件路径 go-admin/config/settings.yml
|
||||
vi ./config/settings.yml
|
||||
|
||||
# 1. 配置文件中修改数据库信息
|
||||
# 注意: settings.database 下对应的配置数据
|
||||
# 2. 确认log路径
|
||||
```
|
||||
|
||||
⚠️注意 在windows环境如果没有安装中CGO,会出现这个问题;
|
||||
|
||||
```bash
|
||||
E:\go-admin>go build
|
||||
# github.com/mattn/go-sqlite3
|
||||
cgo: exec /missing-cc: exec: "/missing-cc": file does not exist
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
D:\Code\go-admin>go build
|
||||
# github.com/mattn/go-sqlite3
|
||||
cgo: exec gcc: exec: "gcc": executable file not found in %PATH%
|
||||
```
|
||||
|
||||
[解决cgo问题进入](https://doc.go-admin.dev/zh-CN/guide/faq#cgo-%E7%9A%84%E9%97%AE%E9%A2%98)
|
||||
|
||||
|
||||
#### 初始化数据库,以及服务启动
|
||||
|
||||
``` bash
|
||||
# 首次配置需要初始化数据库资源信息
|
||||
# macOS or linux 下使用
|
||||
$ ./go-admin migrate -c config/settings.dev.yml
|
||||
|
||||
# ⚠️注意:windows 下使用
|
||||
$ go-admin.exe migrate -c config/settings.dev.yml
|
||||
|
||||
|
||||
# 启动项目,也可以用IDE进行调试
|
||||
# macOS or linux 下使用
|
||||
$ ./go-admin server -c config/settings.yml
|
||||
|
||||
|
||||
# ⚠️注意:windows 下使用
|
||||
$ go-admin.exe server -c config/settings.yml
|
||||
```
|
||||
|
||||
#### sys_api 表的数据如何添加
|
||||
|
||||
在项目启动时,使用`-a true` 系统会自动添加缺少的接口数据
|
||||
```bash
|
||||
./go-admin server -c config/settings.yml -a true
|
||||
```
|
||||
|
||||
#### 使用docker 编译启动
|
||||
|
||||
```shell
|
||||
# 编译镜像
|
||||
docker build -t go-admin .
|
||||
|
||||
# 启动容器,第一个go-admin是容器名字,第二个go-admin是镜像名称
|
||||
# -v 映射配置文件 本地路径:容器路径
|
||||
docker run --name go-admin -p 8000:8000 -v /config/settings.yml:/config/settings.yml -d go-admin-server
|
||||
```
|
||||
|
||||
#### 文档生成
|
||||
|
||||
```bash
|
||||
go generate
|
||||
```
|
||||
|
||||
#### 交叉编译
|
||||
|
||||
```bash
|
||||
# windows
|
||||
env GOOS=windows GOARCH=amd64 go build main.go
|
||||
|
||||
# or
|
||||
# linux
|
||||
env GOOS=linux GOARCH=amd64 go build main.go
|
||||
```
|
||||
|
||||
### UI交互端启动说明
|
||||
|
||||
```bash
|
||||
# 安装 pnpm(若未安装)
|
||||
npm install -g pnpm
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 国内网络可指定镜像源加速
|
||||
pnpm install --registry=https://registry.npmmirror.com
|
||||
|
||||
# 启动服务
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## 📨 互动
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/wx.png" width="180px"></td>
|
||||
<td><img src="https://doc-image.zhangwj.com/img/qrcode_for_gh_b798dc7db30c_258.jpg" width="180px"></td>
|
||||
<td><img src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/qq2.png" width="200px"></td>
|
||||
<td><a href="https://space.bilibili.com/565616721">wenjianzhang</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>微信</td>
|
||||
<td>公众号🔥🔥🔥</td>
|
||||
<td><a target="_blank" href="https://shang.qq.com/wpa/qunwpa?idkey=0f2bf59f5f2edec6a4550c364242c0641f870aa328e468c4ee4b7dbfb392627b"><img border="0" src="https://pub.idqqimg.com/wpa/images/group.png" alt="go-admin技术交流乙号" title="go-admin技术交流乙号"></a></td>
|
||||
<td>哔哩哔哩🔥🔥🔥</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 💎 贡献者
|
||||
|
||||
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wenjianzhang" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/3890175?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/G-Akiraka" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45746659?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/lwnmengjing" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/12806223?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/bing127" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31166183?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/chengxiao" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/1379545?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/NightFire0307" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/19854086?v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/appleboy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/21979?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/ninstein" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/580303?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/kikiyou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17959053?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/horizonzy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/22524871?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Cassuis" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/48005724?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/hqcchina" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/5179057?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/nodece" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16235121?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stephenzhang0713" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/18169290?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zhouxixi-dev" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/100399679?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Jalins" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31172582?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wkf928592" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6063351?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxxiong6" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6983441?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Silicon-He" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52478309?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/GizmoOAO" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20385106?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/bestgopher" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/36840497?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxb1207" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20775558?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/misakichan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16569274?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zhuxuyang" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/19301024?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/mss-boot" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/109259065?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/AuroraV" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/37330199?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Vingurzhou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/57127283?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/haimait" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/40926384?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zyd" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/3446278?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/infnan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/38274826?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/d1y" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45585937?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/qlijin" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/515900?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/logtous
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88697234?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stepway
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/9927079?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/NaturalGao
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/43291304?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/DemoLiang
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/23476007?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/jfcg
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/1410597?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Nicole0724
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/10487328?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
|
||||
## JetBrains 开源证书支持
|
||||
|
||||
`go-admin` 项目一直以来都是在 JetBrains 公司旗下的 GoLand 集成开发环境中进行开发,基于 **free JetBrains Open Source license(s)** 正版免费授权,在此表达我的谢意。
|
||||
|
||||
<a href="https://www.jetbrains.com/?from=kubeadm-ha" target="_blank"><img src="https://raw.githubusercontent.com/panjf2000/illustrations/master/jetbrains/jetbrains-variant-4.png" width="250" align="middle"/></a>
|
||||
|
||||
## 🤝 特别感谢
|
||||
|
||||
1. [ant-design](https://github.com/ant-design/ant-design)
|
||||
2. [ant-design-pro](https://github.com/ant-design/ant-design-pro)
|
||||
2. [arco-design](https://github.com/arco-design/arco-design)
|
||||
2. [arco-design-pro](https://github.com/arco-design/arco-design-pro)
|
||||
4. [gin](https://github.com/gin-gonic/gin)
|
||||
5. [casbin](https://github.com/casbin/casbin)
|
||||
6. [spf13/viper](https://github.com/spf13/viper)
|
||||
7. [gorm](https://github.com/jinzhu/gorm)
|
||||
8. [gin-swagger](https://github.com/swaggo/gin-swagger)
|
||||
9. [jwt-go](https://github.com/dgrijalva/jwt-go)
|
||||
10. [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin)
|
||||
11. [ruoyi-vue](https://gitee.com/y_project/RuoYi-Vue)
|
||||
12. [form-generator](https://github.com/JakHuang/form-generator)
|
||||
|
||||
|
||||
## 🤟 打赏
|
||||
|
||||
> 如果你觉得这个项目帮助到了你,你可以帮作者买一杯果汁表示鼓励 :tropical_drink:
|
||||
|
||||
<img class="no-margin" src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/pay.png" height="200px" >
|
||||
|
||||
## 🤝 链接
|
||||
|
||||
[Go开发者成长线路图](http://www.golangroadmap.com/)
|
||||
|
||||
## 🔑 License
|
||||
|
||||
[MIT](https://github.com/go-admin-team/go-admin/blob/master/LICENSE.md)
|
||||
|
||||
Copyright (c) 2024 wenjianzhang
|
||||
@@ -0,0 +1,345 @@
|
||||
|
||||
# go-admin
|
||||
|
||||
<img align="right" width="320" src="https://raw.githubusercontent.com/wenjianzhang/image/203c5930b9ed08d5cf2fcb4516b85e412f8e0e60/img/go-admin.svg">
|
||||
|
||||
|
||||
[](https://github.com/go-admin-team/go-admin)
|
||||
[](https://github.com/go-admin-team/go-admin/releases)
|
||||
[](https://github.com/go-admin-team/go-admin)
|
||||
|
||||
English | [简体中文](https://github.com/go-admin-team/go-admin/blob/master/README.Zh-cn.md)
|
||||
|
||||
The front-end and back-end separation authority management system based on Gin + Vue + Element UI OR Arco Design is extremely simple to initialize the system. You only need to modify the database connection in the configuration file. The system supports multi-instruction operations. Migration instructions can make it easier to initialize database information. Service instructions It's easy to start the api service.
|
||||
|
||||
[documentation](https://www.go-admin.dev)
|
||||
|
||||
[Front-end project](https://github.com/go-admin-team/go-admin-ui)
|
||||
|
||||
[Video tutorial](https://space.bilibili.com/565616721/channel/detail?cid=125737)
|
||||
|
||||
## 🎬 Online Demo
|
||||
|
||||
Element UI vue demo:[https://vue2.go-admin.dev](https://vue2.go-admin.dev/#/login)
|
||||
> 账号 / 密码: admin / 123456
|
||||
|
||||
Arco Design vue3 demo:[https://vue3.go-admin.dev](https://vue3.go-admin.dev/#/login)
|
||||
> 账号 / 密码: admin / 123456
|
||||
|
||||
antd demo:[https://antd.go-admin.pro](https://antd.go-admin.pro/)
|
||||
> 账号 / 密码: admin / 123456
|
||||
>
|
||||
## ✨ Feature
|
||||
|
||||
- Follow RESTful API design specifications
|
||||
|
||||
- Based on the GIN WEB API framework, it provides rich middleware support (user authentication, cross-domain, access log, tracking ID, etc.)
|
||||
|
||||
- RBAC access control model based on Casbin
|
||||
|
||||
- JWT authentication
|
||||
|
||||
- Support Swagger documents (based on swaggo)
|
||||
|
||||
- Database storage based on GORM, which can expand multiple types of databases
|
||||
|
||||
- Simple model mapping of configuration files to quickly get the desired configuration
|
||||
|
||||
- Code generation tool
|
||||
|
||||
- Form builder
|
||||
|
||||
- Multi-command mode
|
||||
|
||||
- TODO: unit test
|
||||
|
||||
|
||||
## 🎁 Internal
|
||||
|
||||
1. User management: The user is the system operator, this function mainly completes the system user configuration.
|
||||
2. Department management: configure the system organization (company, department, group), and display the tree structure to support data permissions.
|
||||
3. Position management: configure the positions of system users.
|
||||
4. Menu management: configure the system menu, operation authority, button authority identification, interface authority, etc.
|
||||
5. Role management: Role menu permission assignment and role setting are divided into data scope permissions by organization.
|
||||
6. Dictionary management: Maintain some relatively fixed data frequently used in the system.
|
||||
7. Parameter management: dynamically configure common parameters for the system.
|
||||
8. Operation log: system normal operation log record and query; system abnormal information log record and query.
|
||||
9. Login log: The system login log record query contains login exceptions.
|
||||
1. Interface documentation: Automatically generate related api interface documents according to the business code.
|
||||
1. Code generation: According to the data table structure, generate the corresponding addition, deletion, modification, and check corresponding business, and the whole process of visual operation, so that the basic business can be implemented with zero code.
|
||||
1. Form construction: Customize the page style, drag and drop to realize the page layout.
|
||||
1. Service monitoring: View the basic information of some servers.
|
||||
1. Content management: demo function, including classification management and content management. You can refer to the easy to use quick start.
|
||||
|
||||
## Ready to work
|
||||
|
||||
You need to install locally [go] [gin] [node](http://nodejs.org/) 和 [git](https://git-scm.com/)
|
||||
|
||||
At the same time, a series of tutorials including videos and documents are provided. How to complete the downloading to the proficient use, it is strongly recommended that you read these tutorials before you practice this project! ! !
|
||||
|
||||
### Easily implement go-admin to write the first application-documentation tutorial
|
||||
|
||||
[Step 1 - basic content introduction](https://doc.zhangwj.com/guide/intro/tutorial01.html)
|
||||
|
||||
[Step 2 - Practical application - writing database operations](https://doc.zhangwj.com/guide/intro/tutorial02.html)
|
||||
|
||||
### Teach you from getting started to giving up-video tutorial
|
||||
|
||||
[How to start go-admin](https://www.bilibili.com/video/BV1z5411x7JG)
|
||||
|
||||
[Easily implement business using build tools](https://www.bilibili.com/video/BV1Dg4y1i79D)
|
||||
|
||||
[v1.1.0 version code generation tool-free your hands](https://www.bilibili.com/video/BV1N54y1i71P) [Advanced]
|
||||
|
||||
[Explanation of multi-command startup mode and IDE configuration](https://www.bilibili.com/video/BV1Fg4y1q7ph)
|
||||
|
||||
[Configuration instructions for go-admin menu](https://www.bilibili.com/video/BV1Wp4y1D715) [Must see]
|
||||
|
||||
[How to configure menu information and interface information](https://www.bilibili.com/video/BV1zv411B7nG) [Must see]
|
||||
|
||||
[go-admin permission configuration instructions](https://www.bilibili.com/video/BV1rt4y197d3) [Must see]
|
||||
|
||||
[Instructions for use of go-admin data permissions](https://www.bilibili.com/video/BV1LK4y1s71e) [Must see]
|
||||
|
||||
**If you have any questions, please read the above-mentioned usage documents and articles first. If you are not satisfied, welcome to issue and pr. Video tutorials and documents are being updated continuously.**
|
||||
|
||||
## 📦 Local development
|
||||
|
||||
### Environmental requirements
|
||||
|
||||
go 1.26.5
|
||||
|
||||
nodejs: v22+ (v24 LTS recommended)
|
||||
|
||||
package manager: pnpm v9+ (the UI project uses pnpm)
|
||||
|
||||
### Development directory creation
|
||||
|
||||
```bash
|
||||
|
||||
# Create a development directory
|
||||
mkdir goadmin
|
||||
cd goadmin
|
||||
```
|
||||
|
||||
### Get the code
|
||||
|
||||
> Important note: the two projects must be placed in the same folder;
|
||||
|
||||
```bash
|
||||
# Get backend code
|
||||
git clone https://github.com/go-admin-team/go-admin.git
|
||||
|
||||
# Get the front-end code
|
||||
git clone https://github.com/go-admin-team/go-admin-ui.git
|
||||
|
||||
```
|
||||
|
||||
### Startup instructions
|
||||
|
||||
#### Server startup instructions
|
||||
|
||||
```bash
|
||||
# Enter the go-admin backend project
|
||||
cd ./go-admin
|
||||
|
||||
# Update dependencies
|
||||
go mod tidy
|
||||
|
||||
# Compile the project
|
||||
go build
|
||||
|
||||
# Change setting
|
||||
# File path go-admin/config/settings.yml
|
||||
vi ./config/settings.yml
|
||||
|
||||
# 1. Modify the database information in the configuration file
|
||||
# Note: The corresponding configuration data under settings.database
|
||||
# 2. Confirm the log path
|
||||
```
|
||||
|
||||
:::tip ⚠️Note that this problem will occur if CGO is not installed in the windows10+ environment;
|
||||
|
||||
```bash
|
||||
E:\go-admin>go build
|
||||
# github.com/mattn/go-sqlite3
|
||||
cgo: exec /missing-cc: exec: "/missing-cc": file does not exist
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
D:\Code\go-admin>go build
|
||||
# github.com/mattn/go-sqlite3
|
||||
cgo: exec gcc: exec: "gcc": executable file not found in %PATH%
|
||||
```
|
||||
|
||||
[Solve the cgo problem and enter](https://doc.go-admin.dev/guide/faq#cgo-%E7%9A%84%E9%97%AE%E9%A2%98)
|
||||
|
||||
:::
|
||||
|
||||
#### Initialize the database, and start the service
|
||||
|
||||
``` bash
|
||||
# The first configuration needs to initialize the database resource information
|
||||
# Use under macOS or linux
|
||||
$ ./go-admin migrate -c config/settings.dev.yml
|
||||
|
||||
# ⚠️Note: Use under windows
|
||||
$ go-admin.exe migrate -c config/settings.dev.yml
|
||||
|
||||
# Start the project, you can also use the IDE for debugging
|
||||
# Use under macOS or linux
|
||||
$ ./go-admin server -c config/settings.yml
|
||||
|
||||
# ⚠️Note: Use under windows
|
||||
$ go-admin.exe server -c config/settings.yml
|
||||
```
|
||||
|
||||
#### Use docker to compile and start
|
||||
|
||||
```shell
|
||||
# Compile the image
|
||||
docker build -t go-admin .
|
||||
|
||||
|
||||
# Start the container, the first go-admin is the container name, and the second go-admin is the image name
|
||||
# -v Mapping configuration file Local path: container path
|
||||
docker run --name go-admin -p 8000:8000 -v /config/settings.yml:/config/settings.yml -d go-admin-server
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### Generation Document
|
||||
|
||||
```bash
|
||||
go generate
|
||||
```
|
||||
|
||||
#### Cross compile
|
||||
```bash
|
||||
# windows
|
||||
env GOOS=windows GOARCH=amd64 go build main.go
|
||||
|
||||
# or
|
||||
# linux
|
||||
env GOOS=linux GOARCH=amd64 go build main.go
|
||||
```
|
||||
|
||||
### UI interactive terminal startup instructions
|
||||
|
||||
```bash
|
||||
# Install pnpm if you don't have it
|
||||
npm install -g pnpm
|
||||
|
||||
# Installation dependencies
|
||||
pnpm install
|
||||
|
||||
# Start service
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## 📨 Interactive
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/wx.png" width="180px"></td>
|
||||
<td><img src="https://doc-image.zhangwj.com/img/qrcode_for_gh_b798dc7db30c_258.jpg" width="180px"></td>
|
||||
<td><img src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/qq2.png" width="200px"></td>
|
||||
<td><a href="https://space.bilibili.com/565616721">wenjianzhang</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Wechat</td>
|
||||
<td>Wechat公众号🔥🔥🔥</td>
|
||||
<td><a target="_blank" href="https://shang.qq.com/wpa/qunwpa?idkey=0f2bf59f5f2edec6a4550c364242c0641f870aa328e468c4ee4b7dbfb392627b"><img border="0" src="https://pub.idqqimg.com/wpa/images/group.png" alt="go-admin技术交流乙号" title="go-admin技术交流乙号"></a></td>
|
||||
<td>bilibili🔥🔥🔥</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 💎 Contributors
|
||||
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wenjianzhang" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/3890175?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/G-Akiraka" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45746659?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/lwnmengjing" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/12806223?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/bing127" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31166183?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/chengxiao" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/1379545?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/NightFire0307" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/19854086?v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/appleboy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/21979?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/ninstein" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/580303?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/kikiyou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17959053?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/horizonzy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/22524871?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Cassuis" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/48005724?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/hqcchina" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/5179057?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/nodece" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16235121?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stephenzhang0713" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/18169290?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zhouxixi-dev" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/100399679?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Jalins" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31172582?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wkf928592" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6063351?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxxiong6" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6983441?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Silicon-He" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52478309?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/GizmoOAO" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20385106?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/bestgopher" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/36840497?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxb1207" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20775558?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/misakichan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16569274?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zhuxuyang" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/19301024?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/mss-boot" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/109259065?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/AuroraV" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/37330199?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Vingurzhou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/57127283?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/haimait" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/40926384?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zyd" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/3446278?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/infnan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/38274826?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/d1y" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45585937?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/qlijin" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/515900?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/logtous
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88697234?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stepway
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/9927079?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/NaturalGao
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/43291304?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/DemoLiang
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/23476007?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/jfcg
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/1410597?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Nicole0724
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/10487328?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
|
||||
|
||||
|
||||
## JetBrains open source certificate support
|
||||
|
||||
The `go-admin` project has always been developed in the GoLand integrated development environment under JetBrains, based on the **free JetBrains Open Source license(s)** genuine free license. I would like to express my gratitude.
|
||||
|
||||
<a href="https://www.jetbrains.com/?from=kubeadm-ha" target="_blank"><img src="https://raw.githubusercontent.com/panjf2000/illustrations/master/jetbrains/jetbrains-variant-4.png" width="250" align="middle"/></a>
|
||||
|
||||
|
||||
## 🤝 Thanks
|
||||
|
||||
1. [ant-design](https://github.com/ant-design/ant-design)
|
||||
2. [ant-design-pro](https://github.com/ant-design/ant-design-pro)
|
||||
2. [arco-design](https://github.com/arco-design/arco-design)
|
||||
2. [arco-design-pro](https://github.com/arco-design/arco-design-pro)
|
||||
2. [gin](https://github.com/gin-gonic/gin)
|
||||
2. [casbin](https://github.com/casbin/casbin)
|
||||
2. [spf13/viper](https://github.com/spf13/viper)
|
||||
2. [gorm](https://github.com/jinzhu/gorm)
|
||||
2. [gin-swagger](https://github.com/swaggo/gin-swagger)
|
||||
2. [jwt-go](https://github.com/dgrijalva/jwt-go)
|
||||
2. [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin)
|
||||
2. [ruoyi-vue](https://gitee.com/y_project/RuoYi-Vue)
|
||||
2. [form-generator](https://github.com/JakHuang/form-generator)
|
||||
|
||||
## 🤟 Sponsor Us
|
||||
|
||||
> If you think this project helped you, you can buy a glass of juice for the author to show encouragement :tropical_drink:
|
||||
|
||||
<img class="no-margin" src="https://raw.githubusercontent.com/wenjianzhang/image/master/img/pay.png" height="200px" >
|
||||
|
||||
## 🤝 Link
|
||||
- [Go developer growth roadmap](http://www.golangroadmap.com/)
|
||||
- [mss-boot-io](https://docs.mss-boot-io.top/)
|
||||
|
||||
## 🔑 License
|
||||
|
||||
[MIT](https://github.com/go-admin-team/go-admin/blob/master/LICENSE.md)
|
||||
|
||||
Copyright (c) 2022 wenjianzhang
|
||||
@@ -0,0 +1,11 @@
|
||||
# 上游基线
|
||||
|
||||
- 项目:`go-admin-team/go-admin`
|
||||
- 标签:`v2.3.0`
|
||||
- 提交:`92834d6e39ed2e9dff925920e6a323a62177594a`
|
||||
- 来源:https://github.com/go-admin-team/go-admin
|
||||
- 导入日期:2026-08-14
|
||||
|
||||
仓库以源码快照方式导入,不包含嵌套 `.git`。上游要求 Go 1.26.5;本机 Go 1.23 使用默认 `GOTOOLCHAIN=auto` 自动获取固定工具链。
|
||||
|
||||
`common/file_store` 的云存储上传测试需要外部账号、Endpoint 和测试文件,因此加上 `integration` 构建标签。默认 `go test ./...` 不执行这些外部集成测试;显式配置凭据后使用 `go test -tags=integration ./common/file_store`。
|
||||
@@ -0,0 +1 @@
|
||||
theme: jekyll-theme-cayman
|
||||
@@ -0,0 +1,37 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/captcha"
|
||||
)
|
||||
|
||||
type System struct {
|
||||
api.Api
|
||||
}
|
||||
|
||||
// GenerateCaptchaHandler 获取验证码
|
||||
// @Summary 获取验证码
|
||||
// @Description 获取验证码
|
||||
// @Tags 登陆
|
||||
// @Success 200 {object} response.Response{data=string,id=string,msg=string} "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/captcha [get]
|
||||
func (e System) GenerateCaptchaHandler(c *gin.Context) {
|
||||
if err := e.MakeContext(c).Errors; err != nil {
|
||||
e.Error(500, err, "服务初始化失败!")
|
||||
return
|
||||
}
|
||||
id, b64s, answer, err := captcha.DriverDigitFunc()
|
||||
if err != nil {
|
||||
e.Logger.Errorf("DriverDigitFunc error, %s", err.Error())
|
||||
e.Error(500, err, "验证码获取失败")
|
||||
return
|
||||
}
|
||||
e.Logger.Infof("DriverDigitFunc answer: %s", answer)
|
||||
e.Custom(gin.H{
|
||||
"code": 200,
|
||||
"data": b64s,
|
||||
"id": id,
|
||||
"msg": "success",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const INDEX = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>GO-ADMIN欢迎您</title>
|
||||
<style>
|
||||
body{
|
||||
margin:0;
|
||||
padding:0;
|
||||
overflow-y:hidden
|
||||
}
|
||||
</style>
|
||||
<script src="https://libs.baidu.com/jquery/1.9.0/jquery.js"></script>
|
||||
<script type="text/javascript">
|
||||
window.onerror=function(){return true;}
|
||||
$(function(){
|
||||
headerH = 0;
|
||||
var h=$(window).height();
|
||||
$("#iframe").height((h-headerH)+"px");
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<iframe id="iframe" frameborder="0" src="https://www.go-admin.pro" style="width:100%;"></iframe>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
func GoAdmin(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(200, INDEX)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service"
|
||||
"go-admin/app/admin/service/dto"
|
||||
"go-admin/common/actions"
|
||||
)
|
||||
|
||||
type SysApi struct {
|
||||
api.Api
|
||||
}
|
||||
|
||||
// GetPage 获取接口管理列表
|
||||
// @Summary 获取接口管理列表
|
||||
// @Description 获取接口管理列表
|
||||
// @Tags 接口管理
|
||||
// @Param name query string false "名称"
|
||||
// @Param title query string false "标题"
|
||||
// @Param path query string false "地址"
|
||||
// @Param action query string false "类型"
|
||||
// @Param pageSize query int false "页条数"
|
||||
// @Param pageIndex query int false "页码"
|
||||
// @Success 200 {object} response.Response{data=response.Page{list=[]models.SysApi}} "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/sys-api [get]
|
||||
// @Security Bearer
|
||||
func (e SysApi) GetPage(c *gin.Context) {
|
||||
s := service.SysApi{}
|
||||
req := dto.SysApiGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
//数据权限检查
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
list := make([]models.SysApi, 0)
|
||||
var count int64
|
||||
err = s.GetPage(&req, p, &list, &count)
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功")
|
||||
}
|
||||
|
||||
// Get 获取接口管理
|
||||
// @Summary 获取接口管理
|
||||
// @Description 获取接口管理
|
||||
// @Tags 接口管理
|
||||
// @Param id path string false "id"
|
||||
// @Success 200 {object} response.Response{data=models.SysApi} "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/sys-api/{id} [get]
|
||||
// @Security Bearer
|
||||
func (e SysApi) Get(c *gin.Context) {
|
||||
req := dto.SysApiGetReq{}
|
||||
s := service.SysApi{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
var object models.SysApi
|
||||
err = s.Get(&req, p, &object).Error
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
e.OK(object, "查询成功")
|
||||
}
|
||||
|
||||
// Update 修改接口管理
|
||||
// @Summary 修改接口管理
|
||||
// @Description 修改接口管理
|
||||
// @Tags 接口管理
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.SysApiUpdateReq true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "message": "修改成功"}"
|
||||
// @Router /api/v1/sys-api/{id} [put]
|
||||
// @Security Bearer
|
||||
func (e SysApi) Update(c *gin.Context) {
|
||||
req := dto.SysApiUpdateReq{}
|
||||
s := service.SysApi{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
return
|
||||
}
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
err = s.Update(&req, p)
|
||||
if err != nil {
|
||||
e.Error(500, err, "更新失败")
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "更新成功")
|
||||
}
|
||||
|
||||
// DeleteSysApi 删除接口管理
|
||||
// @Summary 删除接口管理
|
||||
// @Description 删除接口管理
|
||||
// @Tags 接口管理
|
||||
// @Param data body dto.SysApiDeleteReq true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "message": "删除成功"}"
|
||||
// @Router /api/v1/sys-api [delete]
|
||||
// @Security Bearer
|
||||
func (e SysApi) DeleteSysApi(c *gin.Context) {
|
||||
req := dto.SysApiDeleteReq{}
|
||||
s := service.SysApi{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
return
|
||||
}
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
err = s.Remove(&req, p)
|
||||
if err != nil {
|
||||
e.Error(500, err, "删除失败")
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service"
|
||||
"go-admin/app/admin/service/dto"
|
||||
)
|
||||
|
||||
type SysConfig struct {
|
||||
api.Api
|
||||
}
|
||||
|
||||
// GetPage 获取配置管理列表
|
||||
// @Summary 获取配置管理列表
|
||||
// @Description 获取配置管理列表
|
||||
// @Tags 配置管理
|
||||
// @Param configName query string false "名称"
|
||||
// @Param configKey query string false "key"
|
||||
// @Param configType query string false "类型"
|
||||
// @Param isFrontend query int false "是否前端"
|
||||
// @Param pageSize query int false "页条数"
|
||||
// @Param pageIndex query int false "页码"
|
||||
// @Success 200 {object} response.Response{data=response.Page{list=[]models.SysApi}} "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/sys-config [get]
|
||||
// @Security Bearer
|
||||
func (e SysConfig) GetPage(c *gin.Context) {
|
||||
s := service.SysConfig{}
|
||||
req := dto.SysConfigGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]models.SysConfig, 0)
|
||||
var count int64
|
||||
err = s.GetPage(&req, &list, &count)
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功")
|
||||
}
|
||||
|
||||
// Get 获取配置管理
|
||||
// @Summary 获取配置管理
|
||||
// @Description 获取配置管理
|
||||
// @Tags 配置管理
|
||||
// @Param id path string false "id"
|
||||
// @Success 200 {object} response.Response{data=models.SysConfig} "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/sys-config/{id} [get]
|
||||
// @Security Bearer
|
||||
func (e SysConfig) Get(c *gin.Context) {
|
||||
req := dto.SysConfigGetReq{}
|
||||
s := service.SysConfig{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
var object models.SysConfig
|
||||
|
||||
err = s.Get(&req, &object)
|
||||
if err != nil {
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
e.OK(object, "查询成功")
|
||||
}
|
||||
|
||||
// Insert 创建配置管理
|
||||
// @Summary 创建配置管理
|
||||
// @Description 创建配置管理
|
||||
// @Tags 配置管理
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.SysConfigControl true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "message": "创建成功"}"
|
||||
// @Router /api/v1/sys-config [post]
|
||||
// @Security Bearer
|
||||
func (e SysConfig) Insert(c *gin.Context) {
|
||||
s := service.SysConfig{}
|
||||
req := dto.SysConfigControl{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
req.SetCreateBy(user.GetUserId(c))
|
||||
|
||||
err = s.Insert(&req)
|
||||
if err != nil {
|
||||
e.Error(500, err, "创建失败")
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "创建成功")
|
||||
}
|
||||
|
||||
// Update 修改配置管理
|
||||
// @Summary 修改配置管理
|
||||
// @Description 修改配置管理
|
||||
// @Tags 配置管理
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.SysConfigControl true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "message": "修改成功"}"
|
||||
// @Router /api/v1/sys-config/{id} [put]
|
||||
// @Security Bearer
|
||||
func (e SysConfig) Update(c *gin.Context) {
|
||||
s := service.SysConfig{}
|
||||
req := dto.SysConfigControl{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
err = s.Update(&req)
|
||||
if err != nil {
|
||||
e.Error(500, err, "更新失败")
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "更新成功")
|
||||
}
|
||||
|
||||
// Delete 删除配置管理
|
||||
// @Summary 删除配置管理
|
||||
// @Description 删除配置管理
|
||||
// @Tags 配置管理
|
||||
// @Param ids body []int false "ids"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "message": "删除成功"}"
|
||||
// @Router /api/v1/sys-config [delete]
|
||||
// @Security Bearer
|
||||
func (e SysConfig) Delete(c *gin.Context) {
|
||||
s := service.SysConfig{}
|
||||
req := dto.SysConfigDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
|
||||
err = s.Remove(&req)
|
||||
if err != nil {
|
||||
e.Error(500, err, "删除失败")
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
}
|
||||
|
||||
// Get2SysApp 获取系统配置信息
|
||||
// @Summary 获取系统前台配置信息,主要注意这里不在验证权限
|
||||
// @Description 获取系统配置信息,主要注意这里不在验证权限
|
||||
// @Tags 配置管理
|
||||
// @Success 200 {object} response.Response{data=map[string]string} "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/app-config [get]
|
||||
func (e SysConfig) Get2SysApp(c *gin.Context) {
|
||||
req := dto.SysConfigGetToSysAppReq{}
|
||||
s := service.SysConfig{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
return
|
||||
}
|
||||
// 控制只读前台的数据
|
||||
req.IsFrontend = "1"
|
||||
list := make([]models.SysConfig, 0)
|
||||
err = s.GetWithKeyList(&req, &list)
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
mp := make(map[string]string)
|
||||
for i := 0; i < len(list); i++ {
|
||||
key := list[i].ConfigKey
|
||||
if key != "" {
|
||||
mp[key] = list[i].ConfigValue
|
||||
}
|
||||
}
|
||||
e.OK(mp, "查询成功")
|
||||
}
|
||||
|
||||
// Get2Set 获取配置
|
||||
// @Summary 获取配置
|
||||
// @Description 界面操作设置配置值的获取
|
||||
// @Tags 配置管理
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Success 200 {object} response.Response{data=map[string]interface{}} "{"code": 200, "message": "修改成功"}"
|
||||
// @Router /api/v1/set-config [get]
|
||||
// @Security Bearer
|
||||
func (e SysConfig) Get2Set(c *gin.Context) {
|
||||
s := service.SysConfig{}
|
||||
req := make([]dto.GetSetSysConfigReq, 0)
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
err = s.GetForSet(&req)
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
m := make(map[string]interface{}, 0)
|
||||
for _, v := range req {
|
||||
m[v.ConfigKey] = v.ConfigValue
|
||||
}
|
||||
e.OK(m, "查询成功")
|
||||
}
|
||||
|
||||
// Update2Set 设置配置
|
||||
// @Summary 设置配置
|
||||
// @Description 界面操作设置配置值
|
||||
// @Tags 配置管理
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body []dto.GetSetSysConfigReq true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "message": "修改成功"}"
|
||||
// @Router /api/v1/set-config [put]
|
||||
// @Security Bearer
|
||||
func (e SysConfig) Update2Set(c *gin.Context) {
|
||||
s := service.SysConfig{}
|
||||
req := make([]dto.GetSetSysConfigReq, 0)
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err = s.UpdateForSet(&req)
|
||||
if err != nil {
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
e.OK("", "更新成功")
|
||||
}
|
||||
|
||||
// GetSysConfigByKEYForService 根据Key获取SysConfig的Service
|
||||
// @Summary 根据Key获取SysConfig的Service
|
||||
// @Description 根据Key获取SysConfig的Service
|
||||
// @Tags 配置管理
|
||||
// @Param configKey path string false "configKey"
|
||||
// @Success 200 {object} response.Response{data=dto.SysConfigByKeyReq} "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/sys-config/{id} [get]
|
||||
// @Security Bearer
|
||||
func (e SysConfig) GetSysConfigByKEYForService(c *gin.Context) {
|
||||
var s = new(service.SysConfig)
|
||||
var req = new(dto.SysConfigByKeyReq)
|
||||
var resp = new(dto.GetSysConfigByKEYForServiceResp)
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(req, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err = s.GetWithKey(req, resp)
|
||||
if err != nil {
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
e.OK(resp, s.Msg)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
"go-admin/app/admin/service"
|
||||
"go-admin/app/admin/service/dto"
|
||||
)
|
||||
|
||||
type SysDept struct {
|
||||
api.Api
|
||||
}
|
||||
|
||||
// GetPage
|
||||
// @Summary 分页部门列表数据
|
||||
// @Description 分页列表
|
||||
// @Tags 部门
|
||||
// @Param deptName query string false "deptName"
|
||||
// @Param deptId query string false "deptId"
|
||||
// @Param position query string false "position"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/dept [get]
|
||||
// @Security Bearer
|
||||
func (e SysDept) GetPage(c *gin.Context) {
|
||||
s := service.SysDept{}
|
||||
req := dto.SysDeptGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
list := make([]models.SysDept, 0)
|
||||
list, err = s.SetDeptPage(&req)
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
e.OK(list, "查询成功")
|
||||
}
|
||||
|
||||
// Get
|
||||
// @Summary 获取部门数据
|
||||
// @Description 获取JSON
|
||||
// @Tags 部门
|
||||
// @Param deptId path string false "deptId"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/dept/{deptId} [get]
|
||||
// @Security Bearer
|
||||
func (e SysDept) Get(c *gin.Context) {
|
||||
s := service.SysDept{}
|
||||
req := dto.SysDeptGetReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
var object models.SysDept
|
||||
|
||||
err = s.Get(&req, &object)
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
e.OK(object, "查询成功")
|
||||
}
|
||||
|
||||
// Insert 添加部门
|
||||
// @Summary 添加部门
|
||||
// @Description 获取JSON
|
||||
// @Tags 部门
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.SysDeptInsertReq true "data"
|
||||
// @Success 200 {string} string "{"code": 200, "message": "添加成功"}"
|
||||
// @Success 200 {string} string "{"code": -1, "message": "添加失败"}"
|
||||
// @Router /api/v1/dept [post]
|
||||
// @Security Bearer
|
||||
func (e SysDept) Insert(c *gin.Context) {
|
||||
s := service.SysDept{}
|
||||
req := dto.SysDeptInsertReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 设置创建人
|
||||
req.SetCreateBy(user.GetUserId(c))
|
||||
err = s.Insert(&req)
|
||||
if err != nil {
|
||||
e.Error(500, err, "创建失败")
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "创建成功")
|
||||
}
|
||||
|
||||
// Update
|
||||
// @Summary 修改部门
|
||||
// @Description 获取JSON
|
||||
// @Tags 部门
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param id path int true "id"
|
||||
// @Param data body dto.SysDeptUpdateReq true "body"
|
||||
// @Success 200 {string} string "{"code": 200, "message": "添加成功"}"
|
||||
// @Success 200 {string} string "{"code": -1, "message": "添加失败"}"
|
||||
// @Router /api/v1/dept/{deptId} [put]
|
||||
// @Security Bearer
|
||||
func (e SysDept) Update(c *gin.Context) {
|
||||
s := service.SysDept{}
|
||||
req := dto.SysDeptUpdateReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
err = s.Update(&req)
|
||||
if err != nil {
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "更新成功")
|
||||
}
|
||||
|
||||
// Delete
|
||||
// @Summary 删除部门
|
||||
// @Description 删除数据
|
||||
// @Tags 部门
|
||||
// @Param data body dto.SysDeptDeleteReq true "body"
|
||||
// @Success 200 {string} string "{"code": 200, "message": "删除成功"}"
|
||||
// @Success 200 {string} string "{"code": -1, "message": "删除失败"}"
|
||||
// @Router /api/v1/dept [delete]
|
||||
// @Security Bearer
|
||||
func (e SysDept) Delete(c *gin.Context) {
|
||||
s := service.SysDept{}
|
||||
req := dto.SysDeptDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err = s.Remove(&req)
|
||||
if err != nil {
|
||||
e.Error(500, err, "删除失败")
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
}
|
||||
|
||||
// Get2Tree 用户管理 左侧部门树
|
||||
func (e SysDept) Get2Tree(c *gin.Context) {
|
||||
s := service.SysDept{}
|
||||
req := dto.SysDeptGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
list := make([]dto.DeptLabel, 0)
|
||||
list, err = s.SetDeptTree(&req)
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
e.OK(list, "")
|
||||
}
|
||||
|
||||
// GetDeptTreeRoleSelect TODO: 此接口需要调整不应该将list和选中放在一起
|
||||
func (e SysDept) GetDeptTreeRoleSelect(c *gin.Context) {
|
||||
s := service.SysDept{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
id, err := pkg.StringToInt(c.Param("roleId"))
|
||||
result, err := s.SetDeptLabel()
|
||||
if err != nil {
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
menuIds := make([]int, 0)
|
||||
if id != 0 {
|
||||
menuIds, err = s.GetWithRoleId(id)
|
||||
if err != nil {
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
e.OK(gin.H{
|
||||
"depts": result,
|
||||
"checkedKeys": menuIds,
|
||||
}, "")
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
"go-admin/app/admin/service"
|
||||
"go-admin/app/admin/service/dto"
|
||||
)
|
||||
|
||||
type SysDictData struct {
|
||||
api.Api
|
||||
}
|
||||
|
||||
// GetPage
|
||||
// @Summary 字典数据列表
|
||||
// @Description 获取JSON
|
||||
// @Tags 字典数据
|
||||
// @Param status query string false "status"
|
||||
// @Param dictCode query string false "dictCode"
|
||||
// @Param dictType query string false "dictType"
|
||||
// @Param pageSize query int false "页条数"
|
||||
// @Param pageIndex query int false "页码"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/dict/data [get]
|
||||
// @Security Bearer
|
||||
func (e SysDictData) GetPage(c *gin.Context) {
|
||||
s := service.SysDictData{}
|
||||
req := dto.SysDictDataGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]models.SysDictData, 0)
|
||||
var count int64
|
||||
err = s.GetPage(&req, &list, &count)
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功")
|
||||
}
|
||||
|
||||
// Get
|
||||
// @Summary 通过编码获取字典数据
|
||||
// @Description 获取JSON
|
||||
// @Tags 字典数据
|
||||
// @Param dictCode path int true "字典编码"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/dict/data/{dictCode} [get]
|
||||
// @Security Bearer
|
||||
func (e SysDictData) Get(c *gin.Context) {
|
||||
s := service.SysDictData{}
|
||||
req := dto.SysDictDataGetReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var object models.SysDictData
|
||||
|
||||
err = s.Get(&req, &object)
|
||||
if err != nil {
|
||||
e.Logger.Warnf("Get error: %s", err.Error())
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
e.OK(object, "查询成功")
|
||||
}
|
||||
|
||||
// Insert
|
||||
// @Summary 添加字典数据
|
||||
// @Description 获取JSON
|
||||
// @Tags 字典数据
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.SysDictDataInsertReq true "data"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "message": "添加成功"}"
|
||||
// @Router /api/v1/dict/data [post]
|
||||
// @Security Bearer
|
||||
func (e SysDictData) Insert(c *gin.Context) {
|
||||
s := service.SysDictData{}
|
||||
req := dto.SysDictDataInsertReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
req.SetCreateBy(user.GetUserId(c))
|
||||
err = s.Insert(&req)
|
||||
if err != nil {
|
||||
e.Error(500, err, "创建失败")
|
||||
return
|
||||
}
|
||||
|
||||
e.OK(req.GetId(), "创建成功")
|
||||
}
|
||||
|
||||
// Update
|
||||
// @Summary 修改字典数据
|
||||
// @Description 获取JSON
|
||||
// @Tags 字典数据
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.SysDictDataUpdateReq true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "message": "修改成功"}"
|
||||
// @Router /api/v1/dict/data/{dictCode} [put]
|
||||
// @Security Bearer
|
||||
func (e SysDictData) Update(c *gin.Context) {
|
||||
s := service.SysDictData{}
|
||||
req := dto.SysDictDataUpdateReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
err = s.Update(&req)
|
||||
if err != nil {
|
||||
e.Error(500, err, "更新失败")
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "更新成功")
|
||||
}
|
||||
|
||||
// Delete
|
||||
// @Summary 删除字典数据
|
||||
// @Description 删除数据
|
||||
// @Tags 字典数据
|
||||
// @Param dictCode body dto.SysDictDataDeleteReq true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "message": "删除成功"}"
|
||||
// @Router /api/v1/dict/data [delete]
|
||||
// @Security Bearer
|
||||
func (e SysDictData) Delete(c *gin.Context) {
|
||||
s := service.SysDictData{}
|
||||
req := dto.SysDictDataDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
err = s.Remove(&req)
|
||||
if err != nil {
|
||||
e.Error(500, err, "删除失败")
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
}
|
||||
|
||||
// GetAll 数据字典根据key获取 业务页面使用
|
||||
// @Summary 数据字典根据key获取
|
||||
// @Description 数据字典根据key获取
|
||||
// @Tags 字典数据
|
||||
// @Param dictType query int true "dictType"
|
||||
// @Success 200 {object} response.Response{data=[]dto.SysDictDataGetAllResp} "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/dict-data/option-select [get]
|
||||
// @Security Bearer
|
||||
func (e SysDictData) GetAll(c *gin.Context) {
|
||||
s := service.SysDictData{}
|
||||
req := dto.SysDictDataGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
list := make([]models.SysDictData, 0)
|
||||
err = s.GetAll(&req, &list)
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
l := make([]dto.SysDictDataGetAllResp, 0)
|
||||
for _, i := range list {
|
||||
d := dto.SysDictDataGetAllResp{}
|
||||
e.Translate(i, &d)
|
||||
l = append(l, d)
|
||||
}
|
||||
|
||||
e.OK(l,"查询成功")
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
"go-admin/app/admin/service"
|
||||
"go-admin/app/admin/service/dto"
|
||||
)
|
||||
|
||||
type SysDictType struct {
|
||||
api.Api
|
||||
}
|
||||
|
||||
// GetPage 字典类型列表数据
|
||||
// @Summary 字典类型列表数据
|
||||
// @Description 获取JSON
|
||||
// @Tags 字典类型
|
||||
// @Param dictName query string false "dictName"
|
||||
// @Param dictId query string false "dictId"
|
||||
// @Param dictType query string false "dictType"
|
||||
// @Param pageSize query int false "页条数"
|
||||
// @Param pageIndex query int false "页码"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/dict/type [get]
|
||||
// @Security Bearer
|
||||
func (e SysDictType) GetPage(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
list := make([]models.SysDictType, 0)
|
||||
var count int64
|
||||
err = s.GetPage(&req, &list, &count)
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功")
|
||||
}
|
||||
|
||||
// Get 字典类型通过字典id获取
|
||||
// @Summary 字典类型通过字典id获取
|
||||
// @Description 获取JSON
|
||||
// @Tags 字典类型
|
||||
// @Param dictId path int true "字典类型编码"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/dict/type/{dictId} [get]
|
||||
// @Security Bearer
|
||||
func (e SysDictType) Get(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeGetReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
var object models.SysDictType
|
||||
err = s.Get(&req, &object)
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
e.OK(object, "查询成功")
|
||||
}
|
||||
|
||||
//Insert 字典类型创建
|
||||
// @Summary 添加字典类型
|
||||
// @Description 获取JSON
|
||||
// @Tags 字典类型
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.SysDictTypeInsertReq true "data"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/dict/type [post]
|
||||
// @Security Bearer
|
||||
func (e SysDictType) Insert(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeInsertReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
req.SetCreateBy(user.GetUserId(c))
|
||||
err = s.Insert(&req)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err,fmt.Sprintf(" 创建字典类型失败,详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "创建成功")
|
||||
}
|
||||
|
||||
// Update
|
||||
// @Summary 修改字典类型
|
||||
// @Description 获取JSON
|
||||
// @Tags 字典类型
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.SysDictTypeUpdateReq true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/dict/type/{dictId} [put]
|
||||
// @Security Bearer
|
||||
func (e SysDictType) Update(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeUpdateReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Error(500, err, err.Error())
|
||||
e.Logger.Error(err)
|
||||
return
|
||||
}
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
err = s.Update(&req)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "更新成功")
|
||||
}
|
||||
|
||||
// Delete
|
||||
// @Summary 删除字典类型
|
||||
// @Description 删除数据
|
||||
// @Tags 字典类型
|
||||
// @Param dictCode body dto.SysDictTypeDeleteReq true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/dict/type [delete]
|
||||
// @Security Bearer
|
||||
func (e SysDictType) Delete(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
err = s.Remove(&req)
|
||||
if err != nil {
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
}
|
||||
|
||||
// GetAll
|
||||
// @Summary 字典类型全部数据 代码生成使用接口
|
||||
// @Description 获取JSON
|
||||
// @Tags 字典类型
|
||||
// @Param dictName query string false "dictName"
|
||||
// @Param dictId query string false "dictId"
|
||||
// @Param dictType query string false "dictType"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/dict/type-option-select [get]
|
||||
// @Security Bearer
|
||||
func (e SysDictType) GetAll(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
list := make([]models.SysDictType, 0)
|
||||
err = s.GetAll(&req, &list)
|
||||
if err != nil {
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
e.OK(list, "查询成功")
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
"go-admin/app/admin/service"
|
||||
"go-admin/app/admin/service/dto"
|
||||
)
|
||||
|
||||
type SysLoginLog struct {
|
||||
api.Api
|
||||
}
|
||||
|
||||
// GetPage 登录日志列表
|
||||
// @Summary 登录日志列表
|
||||
// @Description 获取JSON
|
||||
// @Tags 登录日志
|
||||
// @Param username query string false "用户名"
|
||||
// @Param ipaddr query string false "ip地址"
|
||||
// @Param loginLocation query string false "归属地"
|
||||
// @Param status query string false "状态"
|
||||
// @Param beginTime query string false "开始时间"
|
||||
// @Param endTime query string false "结束时间"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/sys-login-log [get]
|
||||
// @Security Bearer
|
||||
func (e SysLoginLog) GetPage(c *gin.Context) {
|
||||
s := service.SysLoginLog{}
|
||||
req :=dto.SysLoginLogGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
list := make([]models.SysLoginLog, 0)
|
||||
var count int64
|
||||
err = s.GetPage(&req, &list, &count)
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功")
|
||||
}
|
||||
|
||||
// Get 登录日志通过id获取
|
||||
// @Summary 登录日志通过id获取
|
||||
// @Description 获取JSON
|
||||
// @Tags 登录日志
|
||||
// @Param id path string false "id"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/sys-login-log/{id} [get]
|
||||
// @Security Bearer
|
||||
func (e SysLoginLog) Get(c *gin.Context) {
|
||||
s := service.SysLoginLog{}
|
||||
req :=dto.SysLoginLogGetReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
var object models.SysLoginLog
|
||||
err = s.Get(&req, &object)
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
e.OK(object, "查询成功")
|
||||
}
|
||||
|
||||
// Delete 登录日志删除
|
||||
// @Summary 登录日志删除
|
||||
// @Description 登录日志删除
|
||||
// @Tags 登录日志
|
||||
// @Param data body dto.SysLoginLogDeleteReq true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/sys-login-log [delete]
|
||||
// @Security Bearer
|
||||
func (e SysLoginLog) Delete(c *gin.Context) {
|
||||
s := service.SysLoginLog{}
|
||||
req :=dto.SysLoginLogDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
err = s.Remove(&req)
|
||||
if err != nil {
|
||||
e.Error(500, err, "删除失败")
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
"go-admin/app/admin/service"
|
||||
"go-admin/app/admin/service/dto"
|
||||
)
|
||||
|
||||
type SysMenu struct {
|
||||
api.Api
|
||||
}
|
||||
|
||||
// GetPage Menu列表数据
|
||||
// @Summary Menu列表数据
|
||||
// @Description 获取JSON
|
||||
// @Tags 菜单
|
||||
// @Param menuName query string false "menuName"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/menu [get]
|
||||
// @Security Bearer
|
||||
func (e SysMenu) GetPage(c *gin.Context) {
|
||||
s := service.SysMenu{}
|
||||
req := dto.SysMenuGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
var list = make([]models.SysMenu, 0)
|
||||
err = s.GetPage(&req, &list).Error
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
e.OK(list, "查询成功")
|
||||
}
|
||||
|
||||
// Get 获取菜单详情
|
||||
// @Summary Menu详情数据
|
||||
// @Description 获取JSON
|
||||
// @Tags 菜单
|
||||
// @Param id path string false "id"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/menu/{id} [get]
|
||||
// @Security Bearer
|
||||
func (e SysMenu) Get(c *gin.Context) {
|
||||
req := dto.SysMenuGetReq{}
|
||||
s := new(service.SysMenu)
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
var object = models.SysMenu{}
|
||||
|
||||
err = s.Get(&req, &object).Error
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
e.OK(object, "查询成功")
|
||||
}
|
||||
|
||||
// Insert 创建菜单
|
||||
// @Summary 创建菜单
|
||||
// @Description 获取JSON
|
||||
// @Tags 菜单
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.SysMenuInsertReq true "data"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/menu [post]
|
||||
// @Security Bearer
|
||||
func (e SysMenu) Insert(c *gin.Context) {
|
||||
req := dto.SysMenuInsertReq{}
|
||||
s := new(service.SysMenu)
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
// 设置创建人
|
||||
req.SetCreateBy(user.GetUserId(c))
|
||||
err = s.Insert(&req).Error
|
||||
if err != nil {
|
||||
e.Error(500, err, "创建失败")
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "创建成功")
|
||||
}
|
||||
|
||||
// Update 修改菜单
|
||||
// @Summary 修改菜单
|
||||
// @Description 获取JSON
|
||||
// @Tags 菜单
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param id path int true "id"
|
||||
// @Param data body dto.SysMenuUpdateReq true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/menu/{id} [put]
|
||||
// @Security Bearer
|
||||
func (e SysMenu) Update(c *gin.Context) {
|
||||
req := dto.SysMenuUpdateReq{}
|
||||
s := new(service.SysMenu)
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
err = s.Update(&req).Error
|
||||
if err != nil {
|
||||
e.Error(500, err, "更新失败")
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "更新成功")
|
||||
}
|
||||
|
||||
// Delete 删除菜单
|
||||
// @Summary 删除菜单
|
||||
// @Description 删除数据
|
||||
// @Tags 菜单
|
||||
// @Param data body dto.SysMenuDeleteReq true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/menu [delete]
|
||||
// @Security Bearer
|
||||
func (e SysMenu) Delete(c *gin.Context) {
|
||||
control := new(dto.SysMenuDeleteReq)
|
||||
s := new(service.SysMenu)
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(control, binding.JSON).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
err = s.Remove(control).Error
|
||||
if err != nil {
|
||||
e.Logger.Errorf("RemoveSysMenu error, %s", err)
|
||||
e.Error(500, err, "删除失败")
|
||||
return
|
||||
}
|
||||
e.OK(control.GetId(), "删除成功")
|
||||
}
|
||||
|
||||
// GetMenuRole 根据登录角色名称获取菜单列表数据(左菜单使用)
|
||||
// @Summary 根据登录角色名称获取菜单列表数据(左菜单使用)
|
||||
// @Description 获取JSON
|
||||
// @Tags 菜单
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/menurole [get]
|
||||
// @Security Bearer
|
||||
func (e SysMenu) GetMenuRole(c *gin.Context) {
|
||||
s := new(service.SysMenu)
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.SetMenuRole(user.GetRoleName(c))
|
||||
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
e.OK(result, "")
|
||||
}
|
||||
|
||||
// GetMenuTreeSelect 根据角色ID查询菜单下拉树结构
|
||||
// @Summary 角色修改使用的菜单列表
|
||||
// @Description 获取JSON
|
||||
// @Tags 菜单
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param roleId path int true "roleId"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/menuTreeselect/{roleId} [get]
|
||||
// @Security Bearer
|
||||
func (e SysMenu) GetMenuTreeSelect(c *gin.Context) {
|
||||
m := service.SysMenu{}
|
||||
r := service.SysRole{}
|
||||
req := dto.SelectRole{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
MakeService(&m.Service).
|
||||
MakeService(&r.Service).
|
||||
Bind(&req, nil).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := m.SetLabel()
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
menuIds := make([]int, 0)
|
||||
if req.RoleId != 0 {
|
||||
menuIds, err = r.GetRoleMenuId(req.RoleId)
|
||||
if err != nil {
|
||||
e.Error(500, err, "")
|
||||
return
|
||||
}
|
||||
}
|
||||
e.OK(gin.H{
|
||||
"menus": result,
|
||||
"checkedKeys": menuIds,
|
||||
}, "获取成功")
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service"
|
||||
"go-admin/app/admin/service/dto"
|
||||
)
|
||||
|
||||
type SysOperaLog struct {
|
||||
api.Api
|
||||
}
|
||||
|
||||
// GetPage 操作日志列表
|
||||
// @Summary 操作日志列表
|
||||
// @Description 获取JSON
|
||||
// @Tags 操作日志
|
||||
// @Param title query string false "title"
|
||||
// @Param method query string false "method"
|
||||
// @Param requestMethod query string false "requestMethod"
|
||||
// @Param operUrl query string false "operUrl"
|
||||
// @Param operIp query string false "operIp"
|
||||
// @Param status query string false "status"
|
||||
// @Param beginTime query string false "beginTime"
|
||||
// @Param endTime query string false "endTime"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/sys-opera-log [get]
|
||||
// @Security Bearer
|
||||
func (e SysOperaLog) GetPage(c *gin.Context) {
|
||||
s := service.SysOperaLog{}
|
||||
req := new(dto.SysOperaLogGetPageReq)
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(req, binding.Form).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]models.SysOperaLog, 0)
|
||||
var count int64
|
||||
|
||||
err = s.GetPage(req, &list, &count)
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功")
|
||||
}
|
||||
|
||||
// Get 操作日志通过id获取
|
||||
// @Summary 操作日志通过id获取
|
||||
// @Description 获取JSON
|
||||
// @Tags 操作日志
|
||||
// @Param id path string false "id"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/sys-opera-log/{id} [get]
|
||||
// @Security Bearer
|
||||
func (e SysOperaLog) Get(c *gin.Context) {
|
||||
s := new(service.SysOperaLog)
|
||||
req :=dto.SysOperaLogGetReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
var object models.SysOperaLog
|
||||
err = s.Get(&req, &object)
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
e.OK(object, "查询成功")
|
||||
}
|
||||
|
||||
// Delete 操作日志删除
|
||||
// DeleteSysMenu 操作日志删除
|
||||
// @Summary 删除操作日志
|
||||
// @Description 删除数据
|
||||
// @Tags 操作日志
|
||||
// @Param data body dto.SysOperaLogDeleteReq true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/sys-opera-log [delete]
|
||||
// @Security Bearer
|
||||
func (e SysOperaLog) Delete(c *gin.Context) {
|
||||
s := new(service.SysOperaLog)
|
||||
req :=dto.SysOperaLogDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err = s.Remove(&req)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500,err, fmt.Sprintf("删除失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service"
|
||||
"go-admin/app/admin/service/dto"
|
||||
)
|
||||
|
||||
type SysPost struct {
|
||||
api.Api
|
||||
}
|
||||
|
||||
// GetPage
|
||||
// @Summary 岗位列表数据
|
||||
// @Description 获取JSON
|
||||
// @Tags 岗位
|
||||
// @Param postName query string false "postName"
|
||||
// @Param postCode query string false "postCode"
|
||||
// @Param postId query string false "postId"
|
||||
// @Param status query string false "status"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/post [get]
|
||||
// @Security Bearer
|
||||
func (e SysPost) GetPage(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]models.SysPost, 0)
|
||||
var count int64
|
||||
|
||||
err = s.GetPage(&req, &list, &count)
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功")
|
||||
}
|
||||
|
||||
// Get
|
||||
// @Summary 获取岗位信息
|
||||
// @Description 获取JSON
|
||||
// @Tags 岗位
|
||||
// @Param id path int true "编码"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/post/{postId} [get]
|
||||
// @Security Bearer
|
||||
func (e SysPost) Get(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostGetReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
var object models.SysPost
|
||||
|
||||
err = s.Get(&req, &object)
|
||||
if err != nil {
|
||||
e.Error(500, err, fmt.Sprintf("岗位信息获取失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
e.OK(object, "查询成功")
|
||||
}
|
||||
|
||||
// Insert
|
||||
// @Summary 添加岗位
|
||||
// @Description 获取JSON
|
||||
// @Tags 岗位
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.SysPostInsertReq true "data"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/post [post]
|
||||
// @Security Bearer
|
||||
func (e SysPost) Insert(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostInsertReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
req.SetCreateBy(user.GetUserId(c))
|
||||
err = s.Insert(&req)
|
||||
if err != nil {
|
||||
e.Error(500, err, fmt.Sprintf("新建岗位失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "创建成功")
|
||||
}
|
||||
|
||||
// Update
|
||||
// @Summary 修改岗位
|
||||
// @Description 获取JSON
|
||||
// @Tags 岗位
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.SysPostUpdateReq true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/post/{id} [put]
|
||||
// @Security Bearer
|
||||
func (e SysPost) Update(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostUpdateReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
|
||||
err = s.Update(&req)
|
||||
if err != nil {
|
||||
e.Error(500, err, fmt.Sprintf("岗位更新失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "更新成功")
|
||||
}
|
||||
|
||||
// Delete
|
||||
// @Summary 删除岗位
|
||||
// @Description 删除数据
|
||||
// @Tags 岗位
|
||||
// @Param id body dto.SysPostDeleteReq true "请求参数"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/post [delete]
|
||||
// @Security Bearer
|
||||
func (e SysPost) Delete(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
err = s.Remove(&req)
|
||||
if err != nil {
|
||||
e.Error(500, err, fmt.Sprintf("岗位删除失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go-admin/common/global"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
|
||||
"go-admin/app/admin/service"
|
||||
"go-admin/app/admin/service/dto"
|
||||
)
|
||||
|
||||
type SysRole struct {
|
||||
api.Api
|
||||
}
|
||||
|
||||
// GetPage
|
||||
// @Summary 角色列表数据
|
||||
// @Description Get JSON
|
||||
// @Tags 角色/Role
|
||||
// @Param roleName query string false "roleName"
|
||||
// @Param status query string false "status"
|
||||
// @Param roleKey query string false "roleKey"
|
||||
// @Param pageSize query int false "页条数"
|
||||
// @Param pageIndex query int false "页码"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/role [get]
|
||||
// @Security Bearer
|
||||
func (e SysRole) GetPage(c *gin.Context) {
|
||||
s := service.SysRole{}
|
||||
req := dto.SysRoleGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]models.SysRole, 0)
|
||||
var count int64
|
||||
|
||||
err = s.GetPage(&req, &list, &count)
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功")
|
||||
}
|
||||
|
||||
// Get
|
||||
// @Summary 获取Role数据
|
||||
// @Description 获取JSON
|
||||
// @Tags 角色/Role
|
||||
// @Param roleId path string false "roleId"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/role/{id} [get]
|
||||
// @Security Bearer
|
||||
func (e SysRole) Get(c *gin.Context) {
|
||||
s := service.SysRole{}
|
||||
req := dto.SysRoleGetReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf(" %s ", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
var object models.SysRole
|
||||
|
||||
err = s.Get(&req, &object)
|
||||
if err != nil {
|
||||
e.Error(http.StatusUnprocessableEntity, err, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
e.OK(object, "查询成功")
|
||||
}
|
||||
|
||||
// Insert
|
||||
// @Summary 创建角色
|
||||
// @Description 获取JSON
|
||||
// @Tags 角色/Role
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.SysRoleInsertReq true "data"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/role [post]
|
||||
// @Security Bearer
|
||||
func (e SysRole) Insert(c *gin.Context) {
|
||||
s := service.SysRole{}
|
||||
req := dto.SysRoleInsertReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 设置创建人
|
||||
req.CreateBy = user.GetUserId(c)
|
||||
if req.Status == "" {
|
||||
req.Status = "2"
|
||||
}
|
||||
cb := sdk.Runtime.GetCasbinKey(c.Request.Host)
|
||||
err = s.Insert(&req, cb)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, "创建失败,"+err.Error())
|
||||
return
|
||||
}
|
||||
_, err = global.LoadPolicy(c)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, "创建失败,"+err.Error())
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "创建成功")
|
||||
}
|
||||
|
||||
// Update 修改用户角色
|
||||
// @Summary 修改用户角色
|
||||
// @Description 获取JSON
|
||||
// @Tags 角色/Role
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.SysRoleUpdateReq true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/role/{id} [put]
|
||||
// @Security Bearer
|
||||
func (e SysRole) Update(c *gin.Context) {
|
||||
s := service.SysRole{}
|
||||
req := dto.SysRoleUpdateReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil, binding.JSON).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
cb := sdk.Runtime.GetCasbinKey(c.Request.Host)
|
||||
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
|
||||
err = s.Update(&req, cb)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = global.LoadPolicy(c)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, "更新失败,"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
e.OK(req.GetId(), "更新成功")
|
||||
}
|
||||
|
||||
// Delete
|
||||
// @Summary 删除用户角色
|
||||
// @Description 删除数据
|
||||
// @Tags 角色/Role
|
||||
// @Param data body dto.SysRoleDeleteReq true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/role [delete]
|
||||
// @Security Bearer
|
||||
func (e SysRole) Delete(c *gin.Context) {
|
||||
s := new(service.SysRole)
|
||||
req := dto.SysRoleDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("删除角色 %v 失败,\r\n失败信息 %s", req.Ids, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
cb := sdk.Runtime.GetCasbinKey(c.Request.Host)
|
||||
err = s.Remove(&req, cb)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, "")
|
||||
return
|
||||
}
|
||||
|
||||
e.OK(req.GetId(), fmt.Sprintf("删除角色角色 %v 状态成功!", req.GetId()))
|
||||
}
|
||||
|
||||
// Update2Status 修改用户角色状态
|
||||
// @Summary 修改用户角色
|
||||
// @Description 获取JSON
|
||||
// @Tags 角色/Role
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.UpdateStatusReq true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/role-status/{id} [put]
|
||||
// @Security Bearer
|
||||
func (e SysRole) Update2Status(c *gin.Context) {
|
||||
s := service.SysRole{}
|
||||
req := dto.UpdateStatusReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("更新角色状态失败,失败原因:%s ", err.Error()))
|
||||
return
|
||||
}
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
err = s.UpdateStatus(&req)
|
||||
if err != nil {
|
||||
e.Error(500, err, fmt.Sprintf("更新角色状态失败,失败原因:%s ", err.Error()))
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), fmt.Sprintf("更新角色 %v 状态成功!", req.GetId()))
|
||||
}
|
||||
|
||||
// Update2DataScope 更新角色数据权限
|
||||
// @Summary 更新角色数据权限
|
||||
// @Description 获取JSON
|
||||
// @Tags 角色/Role
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.RoleDataScopeReq true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/role-status/{id} [put]
|
||||
// @Security Bearer
|
||||
func (e SysRole) Update2DataScope(c *gin.Context) {
|
||||
s := service.SysRole{}
|
||||
req := dto.RoleDataScopeReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
data := &models.SysRole{
|
||||
RoleId: req.RoleId,
|
||||
DataScope: req.DataScope,
|
||||
DeptIds: req.DeptIds,
|
||||
}
|
||||
data.UpdateBy = user.GetUserId(c)
|
||||
err = s.UpdateDataScope(&req).Error
|
||||
if err != nil {
|
||||
e.Error(500, err, fmt.Sprintf("更新角色数据权限失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
e.OK(nil, "操作成功")
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"go-admin/app/admin/models"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"go-admin/app/admin/service"
|
||||
"go-admin/app/admin/service/dto"
|
||||
"go-admin/common/actions"
|
||||
)
|
||||
|
||||
type SysUser struct {
|
||||
api.Api
|
||||
}
|
||||
|
||||
// GetPage
|
||||
// @Summary 列表用户信息数据
|
||||
// @Description 获取JSON
|
||||
// @Tags 用户
|
||||
// @Param username query string false "username"
|
||||
// @Success 200 {string} {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/sys-user [get]
|
||||
// @Security Bearer
|
||||
func (e SysUser) GetPage(c *gin.Context) {
|
||||
s := service.SysUser{}
|
||||
req := dto.SysUserGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
//数据权限检查
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
|
||||
list := make([]models.SysUser, 0)
|
||||
var count int64
|
||||
|
||||
err = s.GetPage(&req, p, &list, &count)
|
||||
if err != nil {
|
||||
e.Error(500, err, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功")
|
||||
}
|
||||
|
||||
// Get
|
||||
// @Summary 获取用户
|
||||
// @Description 获取JSON
|
||||
// @Tags 用户
|
||||
// @Param userId path int true "用户编码"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/sys-user/{userId} [get]
|
||||
// @Security Bearer
|
||||
func (e SysUser) Get(c *gin.Context) {
|
||||
s := service.SysUser{}
|
||||
req := dto.SysUserById{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
var object models.SysUser
|
||||
//数据权限检查
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
err = s.Get(&req, p, &object)
|
||||
if err != nil {
|
||||
e.Error(http.StatusUnprocessableEntity, err, "查询失败")
|
||||
return
|
||||
}
|
||||
e.OK(object, "查询成功")
|
||||
}
|
||||
|
||||
// Insert
|
||||
// @Summary 创建用户
|
||||
// @Description 获取JSON
|
||||
// @Tags 用户
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.SysUserInsertReq true "用户数据"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/sys-user [post]
|
||||
// @Security Bearer
|
||||
func (e SysUser) Insert(c *gin.Context) {
|
||||
s := service.SysUser{}
|
||||
req := dto.SysUserInsertReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
// 设置创建人
|
||||
req.SetCreateBy(user.GetUserId(c))
|
||||
err = s.Insert(&req)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
e.OK(req.GetId(), "创建成功")
|
||||
}
|
||||
|
||||
// Update
|
||||
// @Summary 修改用户数据
|
||||
// @Description 获取JSON
|
||||
// @Tags 用户
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.SysUserUpdateReq true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/sys-user/{userId} [put]
|
||||
// @Security Bearer
|
||||
func (e SysUser) Update(c *gin.Context) {
|
||||
s := service.SysUser{}
|
||||
req := dto.SysUserUpdateReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
|
||||
//数据权限检查
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
|
||||
err = s.Update(&req, p)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "更新成功")
|
||||
}
|
||||
|
||||
// Delete
|
||||
// @Summary 删除用户数据
|
||||
// @Description 删除数据
|
||||
// @Tags 用户
|
||||
// @Param userId path int true "userId"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/sys-user/{userId} [delete]
|
||||
// @Security Bearer
|
||||
func (e SysUser) Delete(c *gin.Context) {
|
||||
s := service.SysUser{}
|
||||
req := dto.SysUserById{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 设置编辑人
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
|
||||
// 数据权限检查
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
|
||||
err = s.Remove(&req, p)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
}
|
||||
|
||||
// InsetAvatar
|
||||
// @Summary 修改头像
|
||||
// @Description 获取JSON
|
||||
// @Tags 个人中心
|
||||
// @Accept multipart/form-data
|
||||
// @Param file formData file true "file"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/user/avatar [post]
|
||||
// @Security Bearer
|
||||
func (e SysUser) InsetAvatar(c *gin.Context) {
|
||||
s := service.SysUser{}
|
||||
req := dto.UpdateSysUserAvatarReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
// 数据权限检查
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
form, _ := c.MultipartForm()
|
||||
files := form.File["upload[]"]
|
||||
guid := uuid.New().String()
|
||||
filPath := "static/uploadfile/" + guid + ".jpg"
|
||||
for _, file := range files {
|
||||
e.Logger.Debugf("upload avatar file: %s", file.Filename)
|
||||
// 上传文件至指定目录
|
||||
err = c.SaveUploadedFile(file, filPath)
|
||||
if err != nil {
|
||||
e.Logger.Errorf("save file error, %s", err.Error())
|
||||
e.Error(500, err, "")
|
||||
return
|
||||
}
|
||||
}
|
||||
req.UserId = p.UserId
|
||||
req.Avatar = "/" + filPath
|
||||
|
||||
err = s.UpdateAvatar(&req, p)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
return
|
||||
}
|
||||
e.OK(filPath, "修改成功")
|
||||
}
|
||||
|
||||
// UpdateStatus 修改用户状态
|
||||
// @Summary 修改用户状态
|
||||
// @Description 获取JSON
|
||||
// @Tags 用户
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.UpdateSysUserStatusReq true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/user/status [put]
|
||||
// @Security Bearer
|
||||
func (e SysUser) UpdateStatus(c *gin.Context) {
|
||||
s := service.SysUser{}
|
||||
req := dto.UpdateSysUserStatusReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
|
||||
//数据权限检查
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
|
||||
err = s.UpdateStatus(&req, p)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "更新成功")
|
||||
}
|
||||
|
||||
// ResetPwd 重置用户密码
|
||||
// @Summary 重置用户密码
|
||||
// @Description 获取JSON
|
||||
// @Tags 用户
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.ResetSysUserPwdReq true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/user/pwd/reset [put]
|
||||
// @Security Bearer
|
||||
func (e SysUser) ResetPwd(c *gin.Context) {
|
||||
s := service.SysUser{}
|
||||
req := dto.ResetSysUserPwdReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
req.SetUpdateBy(user.GetUserId(c))
|
||||
|
||||
//数据权限检查
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
|
||||
err = s.ResetPwd(&req, p)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "更新成功")
|
||||
}
|
||||
|
||||
// UpdatePwd
|
||||
// @Summary 修改密码
|
||||
// @Description 获取JSON
|
||||
// @Tags 用户
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param data body dto.PassWord true "body"
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/user/pwd/set [put]
|
||||
// @Security Bearer
|
||||
func (e SysUser) UpdatePwd(c *gin.Context) {
|
||||
s := service.SysUser{}
|
||||
req := dto.PassWord{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 数据权限检查
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
var hash []byte
|
||||
if hash, err = bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost); err != nil {
|
||||
req.NewPassword = string(hash)
|
||||
}
|
||||
|
||||
err = s.UpdatePwd(user.GetUserId(c), req.OldPassword, req.NewPassword, p)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(http.StatusForbidden, err, "密码修改失败")
|
||||
return
|
||||
}
|
||||
|
||||
e.OK(nil, "密码修改成功")
|
||||
}
|
||||
|
||||
// GetProfile
|
||||
// @Summary 获取个人中心用户
|
||||
// @Description 获取JSON
|
||||
// @Tags 个人中心
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/user/profile [get]
|
||||
// @Security Bearer
|
||||
func (e SysUser) GetProfile(c *gin.Context) {
|
||||
s := service.SysUser{}
|
||||
req := dto.SysUserById{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
req.Id = user.GetUserId(c)
|
||||
|
||||
sysUser := models.SysUser{}
|
||||
roles := make([]models.SysRole, 0)
|
||||
posts := make([]models.SysPost, 0)
|
||||
err = s.GetProfile(&req, &sysUser, &roles, &posts)
|
||||
if err != nil {
|
||||
e.Logger.Errorf("get user profile error, %s", err.Error())
|
||||
e.Error(500, err, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
e.OK(gin.H{
|
||||
"user": sysUser,
|
||||
"roles": roles,
|
||||
"posts": posts,
|
||||
}, "查询成功")
|
||||
}
|
||||
|
||||
// GetInfo
|
||||
// @Summary 获取个人信息
|
||||
// @Description 获取JSON
|
||||
// @Tags 个人中心
|
||||
// @Success 200 {object} response.Response "{"code": 200, "data": [...]}"
|
||||
// @Router /api/v1/getinfo [get]
|
||||
// @Security Bearer
|
||||
func (e SysUser) GetInfo(c *gin.Context) {
|
||||
req := dto.SysUserById{}
|
||||
s := service.SysUser{}
|
||||
r := service.SysRole{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
MakeService(&r.Service).
|
||||
MakeService(&s.Service).
|
||||
Errors
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
p := actions.GetPermissionFromContext(c)
|
||||
var roles = make([]string, 1)
|
||||
roles[0] = user.GetRoleName(c)
|
||||
var permissions = make([]string, 1)
|
||||
permissions[0] = "*:*:*"
|
||||
var buttons = make([]string, 1)
|
||||
buttons[0] = "*:*:*"
|
||||
|
||||
var mp = make(map[string]interface{})
|
||||
mp["roles"] = roles
|
||||
if user.GetRoleName(c) == "admin" || user.GetRoleName(c) == "系统管理员" {
|
||||
mp["permissions"] = permissions
|
||||
mp["buttons"] = buttons
|
||||
} else {
|
||||
list, _ := r.GetById(user.GetRoleId(c))
|
||||
mp["permissions"] = list
|
||||
mp["buttons"] = list
|
||||
}
|
||||
sysUser := models.SysUser{}
|
||||
req.Id = user.GetUserId(c)
|
||||
err = s.Get(&req, p, &sysUser)
|
||||
if err != nil {
|
||||
e.Error(http.StatusUnauthorized, err, "登录失败")
|
||||
return
|
||||
}
|
||||
mp["introduction"] = " am a super administrator"
|
||||
mp["avatar"] = "https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif"
|
||||
if sysUser.Avatar != "" {
|
||||
mp["avatar"] = sysUser.Avatar
|
||||
}
|
||||
mp["userName"] = sysUser.Username
|
||||
mp["userId"] = sysUser.UserId
|
||||
mp["deptId"] = sysUser.DeptId
|
||||
mp["name"] = sysUser.NickName
|
||||
mp["code"] = 200
|
||||
e.OK(mp, "")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
type CasbinRule struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
Ptype string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V0 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V1 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V2 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V3 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V4 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
V5 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||
}
|
||||
|
||||
func (CasbinRule) TableName() string {
|
||||
return "sys_casbin_rule"
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"gorm.io/gorm"
|
||||
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
)
|
||||
|
||||
type DataPermission struct {
|
||||
DataScope string
|
||||
UserId int
|
||||
DeptId int
|
||||
RoleId int
|
||||
}
|
||||
|
||||
func (e *DataPermission) GetDataScope(tableName string, db *gorm.DB) (*gorm.DB, error) {
|
||||
|
||||
if !config.ApplicationConfig.EnableDP {
|
||||
usageStr := `数据权限已经为您` + pkg.Green(`关闭`) + `,如需开启请参考配置文件字段说明`
|
||||
log.Debug("%s\n", usageStr)
|
||||
return db, nil
|
||||
}
|
||||
user := new(SysUser)
|
||||
role := new(SysRole)
|
||||
err := db.Find(user, e.UserId).Error
|
||||
if err != nil {
|
||||
return nil, errors.New("获取用户数据出错 msg:" + err.Error())
|
||||
}
|
||||
err = db.Find(role, user.RoleId).Error
|
||||
if err != nil {
|
||||
return nil, errors.New("获取用户数据出错 msg:" + err.Error())
|
||||
}
|
||||
if role.DataScope == "2" {
|
||||
db = db.Where(tableName+".create_by in (select sys_user.user_id from sys_role_dept left join sys_user on sys_user.dept_id=sys_role_dept.dept_id where sys_role_dept.role_id = ?)", user.RoleId)
|
||||
}
|
||||
if role.DataScope == "3" {
|
||||
db = db.Where(tableName+".create_by in (SELECT user_id from sys_user where dept_id = ? )", user.DeptId)
|
||||
}
|
||||
if role.DataScope == "4" {
|
||||
db = db.Where(tableName+".create_by in (SELECT user_id from sys_user where sys_user.dept_id in(select dept_id from sys_dept where dept_path like ? ))", "%"+pkg.IntToString(user.DeptId)+"%")
|
||||
}
|
||||
if role.DataScope == "5" || role.DataScope == "" {
|
||||
db = db.Where(tableName+".create_by = ?", e.UserId)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
//func DataScopes(tableName string, userId int) func(db *gorm.DB) *gorm.DB {
|
||||
// return func(db *gorm.DB) *gorm.DB {
|
||||
// user := new(SysUser)
|
||||
// role := new(SysRole)
|
||||
// user.UserId = userId
|
||||
// err := db.Find(user, userId).Error
|
||||
// if err != nil {
|
||||
// db.Error = errors.New("获取用户数据出错 msg:" + err.Error())
|
||||
// return db
|
||||
// }
|
||||
// err = db.Find(role, user.RoleId).Error
|
||||
// if err != nil {
|
||||
// db.Error = errors.New("获取用户数据出错 msg:" + err.Error())
|
||||
// return db
|
||||
// }
|
||||
// if role.DataScope == "2" {
|
||||
// return db.Where(tableName+".create_by in (select sys_user.user_id from sys_role_dept left join sys_user on sys_user.dept_id=sys_role_dept.dept_id where sys_role_dept.role_id = ?)", user.RoleId)
|
||||
// }
|
||||
// if role.DataScope == "3" {
|
||||
// return db.Where(tableName+".create_by in (SELECT user_id from sys_user where dept_id = ? )", user.DeptId)
|
||||
// }
|
||||
// if role.DataScope == "4" {
|
||||
// return db.Where(tableName+".create_by in (SELECT user_id from sys_user where sys_user.dept_id in(select dept_id from sys_dept where dept_path like ? ))", "%"+pkg.IntToString(user.DeptId)+"%")
|
||||
// }
|
||||
// if role.DataScope == "5" || role.DataScope == "" {
|
||||
// return db.Where(tableName+".create_by = ?", userId)
|
||||
// }
|
||||
// return db
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,55 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go-admin/common/global"
|
||||
"gorm.io/gorm"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func InitDb(db *gorm.DB) (err error) {
|
||||
filePath := "config/db.sql"
|
||||
err = ExecSql(db, filePath)
|
||||
if global.Driver == "postgres" {
|
||||
filePath = "config/pg.sql"
|
||||
err = ExecSql(db, filePath)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ExecSql(db *gorm.DB, filePath string) error {
|
||||
sql, err := Ioutil(filePath)
|
||||
if err != nil {
|
||||
fmt.Println("数据库基础数据初始化脚本读取失败!原因:", err.Error())
|
||||
return err
|
||||
}
|
||||
sqlList := strings.Split(sql, ";")
|
||||
for i := 0; i < len(sqlList)-1; i++ {
|
||||
if strings.Contains(sqlList[i], "--") {
|
||||
fmt.Println(sqlList[i])
|
||||
continue
|
||||
}
|
||||
sql := strings.Replace(sqlList[i]+";", "\n", "", -1)
|
||||
sql = strings.TrimSpace(sql)
|
||||
if err = db.Exec(sql).Error; err != nil {
|
||||
log.Printf("error sql: %s", sql)
|
||||
if !strings.Contains(err.Error(), "Query was empty") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Ioutil(filePath string) (string, error) {
|
||||
if contents, err := ioutil.ReadFile(filePath); err == nil {
|
||||
//因为contents是[]byte类型,直接转换成string类型后会多一行空格,需要使用strings.Replace替换换行符
|
||||
result := strings.Replace(string(contents), "\n", "", 1)
|
||||
fmt.Println("Use ioutil.ReadFile to read a file:", result)
|
||||
return result, nil
|
||||
} else {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/bitly/go-simplejson"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/runtime"
|
||||
"github.com/go-admin-team/go-admin-core/storage"
|
||||
|
||||
"go-admin/common/models"
|
||||
)
|
||||
|
||||
type SysApi struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement;comment:主键编码"`
|
||||
Handle string `json:"handle" gorm:"size:128;comment:handle"`
|
||||
Title string `json:"title" gorm:"size:128;comment:标题"`
|
||||
Path string `json:"path" gorm:"size:128;comment:地址"`
|
||||
Action string `json:"action" gorm:"size:16;comment:请求类型"`
|
||||
Type string `json:"type" gorm:"size:16;comment:接口类型"`
|
||||
models.ModelTime
|
||||
models.ControlBy
|
||||
}
|
||||
|
||||
func (*SysApi) TableName() string {
|
||||
return "sys_api"
|
||||
}
|
||||
|
||||
func (e *SysApi) Generate() models.ActiveRecord {
|
||||
o := *e
|
||||
return &o
|
||||
}
|
||||
|
||||
func (e *SysApi) GetId() interface{} {
|
||||
return e.Id
|
||||
}
|
||||
|
||||
func SaveSysApi(message storage.Messager) (err error) {
|
||||
var rb []byte
|
||||
rb, err = json.Marshal(message.GetValues())
|
||||
if err != nil {
|
||||
err = fmt.Errorf("json Marshal error, %v", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
var l runtime.Routers
|
||||
err = json.Unmarshal(rb, &l)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("json Unmarshal error, %s", err.Error())
|
||||
return err
|
||||
}
|
||||
dbList := sdk.Runtime.GetDb()
|
||||
for _, d := range dbList {
|
||||
for _, v := range l.List {
|
||||
if v.HttpMethod != "HEAD" ||
|
||||
strings.Contains(v.RelativePath, "/swagger/") ||
|
||||
strings.Contains(v.RelativePath, "/static/") ||
|
||||
strings.Contains(v.RelativePath, "/form-generator/") ||
|
||||
strings.Contains(v.RelativePath, "/sys/tables") {
|
||||
|
||||
// 根据接口方法注释里的@Summary填充接口名称,适用于代码生成器
|
||||
// 可在此处增加配置路径前缀的if判断,只对代码生成的自建应用进行定向的接口名称填充
|
||||
jsonFile, _ := ioutil.ReadFile("docs/swagger.json")
|
||||
jsonData, _ := simplejson.NewFromReader(bytes.NewReader(jsonFile))
|
||||
urlPath := v.RelativePath
|
||||
idPatten := "(.*)/:(\\w+)" // 正则替换,把:id换成{id}
|
||||
reg, _ := regexp.Compile(idPatten)
|
||||
if reg.MatchString(urlPath) {
|
||||
urlPath = reg.ReplaceAllString(v.RelativePath, "${1}/{${2}}") // 把:id换成{id}
|
||||
}
|
||||
apiTitle, _ := jsonData.Get("paths").Get(urlPath).Get(strings.ToLower(v.HttpMethod)).Get("summary").String()
|
||||
|
||||
err := d.Debug().Where(SysApi{Path: v.RelativePath, Action: v.HttpMethod}).
|
||||
Attrs(SysApi{Handle: v.Handler, Title: apiTitle}).
|
||||
FirstOrCreate(&SysApi{}).
|
||||
//Update("handle", v.Handler).
|
||||
Error
|
||||
if err != nil {
|
||||
err := fmt.Errorf("Models SaveSysApi error: %s \r\n ", err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"go-admin/common/models"
|
||||
)
|
||||
|
||||
type SysConfig struct {
|
||||
models.Model
|
||||
ConfigName string `json:"configName" gorm:"size:128;comment:ConfigName"` //
|
||||
ConfigKey string `json:"configKey" gorm:"size:128;comment:ConfigKey"` //
|
||||
ConfigValue string `json:"configValue" gorm:"size:255;comment:ConfigValue"` //
|
||||
ConfigType string `json:"configType" gorm:"size:64;comment:ConfigType"`
|
||||
IsFrontend string `json:"isFrontend" gorm:"size:64;comment:是否前台"` //
|
||||
Remark string `json:"remark" gorm:"size:128;comment:Remark"` //
|
||||
models.ControlBy
|
||||
models.ModelTime
|
||||
}
|
||||
|
||||
func (*SysConfig) TableName() string {
|
||||
return "sys_config"
|
||||
}
|
||||
|
||||
func (e *SysConfig) Generate() models.ActiveRecord {
|
||||
o := *e
|
||||
return &o
|
||||
}
|
||||
|
||||
func (e *SysConfig) GetId() interface{} {
|
||||
return e.Id
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package models
|
||||
|
||||
import "go-admin/common/models"
|
||||
|
||||
type SysDept struct {
|
||||
DeptId int `json:"deptId" gorm:"primaryKey;autoIncrement;"` //部门编码
|
||||
ParentId int `json:"parentId" gorm:""` //上级部门
|
||||
DeptPath string `json:"deptPath" gorm:"size:255;"` //
|
||||
DeptName string `json:"deptName" gorm:"size:128;"` //部门名称
|
||||
Sort int `json:"sort" gorm:"size:4;"` //排序
|
||||
Leader string `json:"leader" gorm:"size:128;"` //负责人
|
||||
Phone string `json:"phone" gorm:"size:11;"` //手机
|
||||
Email string `json:"email" gorm:"size:64;"` //邮箱
|
||||
Status int `json:"status" gorm:"size:4;"` //状态
|
||||
models.ControlBy
|
||||
models.ModelTime
|
||||
DataScope string `json:"dataScope" gorm:"-"`
|
||||
Params string `json:"params" gorm:"-"`
|
||||
Children []SysDept `json:"children" gorm:"-"`
|
||||
}
|
||||
|
||||
func (*SysDept) TableName() string {
|
||||
return "sys_dept"
|
||||
}
|
||||
|
||||
func (e *SysDept) Generate() models.ActiveRecord {
|
||||
o := *e
|
||||
return &o
|
||||
}
|
||||
|
||||
func (e *SysDept) GetId() interface{} {
|
||||
return e.DeptId
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"go-admin/common/models"
|
||||
)
|
||||
|
||||
type SysDictData struct {
|
||||
DictCode int `json:"dictCode" gorm:"primaryKey;column:dict_code;autoIncrement;comment:主键编码"`
|
||||
DictSort int `json:"dictSort" gorm:"size:20;comment:DictSort"`
|
||||
DictLabel string `json:"dictLabel" gorm:"size:128;comment:DictLabel"`
|
||||
DictValue string `json:"dictValue" gorm:"size:255;comment:DictValue"`
|
||||
DictType string `json:"dictType" gorm:"size:64;comment:DictType"`
|
||||
CssClass string `json:"cssClass" gorm:"size:128;comment:CssClass"`
|
||||
ListClass string `json:"listClass" gorm:"size:128;comment:ListClass"`
|
||||
IsDefault string `json:"isDefault" gorm:"size:8;comment:IsDefault"`
|
||||
Status int `json:"status" gorm:"size:4;comment:Status"`
|
||||
Default string `json:"default" gorm:"size:8;comment:Default"`
|
||||
Remark string `json:"remark" gorm:"size:255;comment:Remark"`
|
||||
models.ControlBy
|
||||
models.ModelTime
|
||||
}
|
||||
|
||||
func (*SysDictData) TableName() string {
|
||||
return "sys_dict_data"
|
||||
}
|
||||
|
||||
func (e *SysDictData) Generate() models.ActiveRecord {
|
||||
o := *e
|
||||
return &o
|
||||
}
|
||||
|
||||
func (e *SysDictData) GetId() interface{} {
|
||||
return e.DictCode
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"go-admin/common/models"
|
||||
)
|
||||
|
||||
type SysDictType struct {
|
||||
ID int `json:"id" gorm:"primaryKey;column:dict_id;autoIncrement;comment:主键编码"`
|
||||
DictName string `json:"dictName" gorm:"size:128;comment:DictName"`
|
||||
DictType string `json:"dictType" gorm:"size:128;comment:DictType"`
|
||||
Status int `json:"status" gorm:"size:4;comment:Status"`
|
||||
Remark string `json:"remark" gorm:"size:255;comment:Remark"`
|
||||
models.ControlBy
|
||||
models.ModelTime
|
||||
}
|
||||
|
||||
func (*SysDictType) TableName() string {
|
||||
return "sys_dict_type"
|
||||
}
|
||||
|
||||
func (e *SysDictType) Generate() models.ActiveRecord {
|
||||
o := *e
|
||||
return &o
|
||||
}
|
||||
|
||||
func (e *SysDictType) GetId() interface{} {
|
||||
return e.ID
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/storage"
|
||||
|
||||
"go-admin/common/models"
|
||||
)
|
||||
|
||||
type SysLoginLog struct {
|
||||
models.Model
|
||||
Username string `json:"username" gorm:"size:128;comment:用户名"`
|
||||
Status string `json:"status" gorm:"size:4;comment:状态"`
|
||||
Ipaddr string `json:"ipaddr" gorm:"size:255;comment:ip地址"`
|
||||
LoginLocation string `json:"loginLocation" gorm:"size:255;comment:归属地"`
|
||||
Browser string `json:"browser" gorm:"size:255;comment:浏览器"`
|
||||
Os string `json:"os" gorm:"size:255;comment:系统"`
|
||||
Platform string `json:"platform" gorm:"size:255;comment:固件"`
|
||||
LoginTime time.Time `json:"loginTime" gorm:"comment:登录时间"`
|
||||
Remark string `json:"remark" gorm:"size:255;comment:备注"`
|
||||
Msg string `json:"msg" gorm:"size:255;comment:信息"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"comment:创建时间"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"comment:最后更新时间"`
|
||||
models.ControlBy
|
||||
}
|
||||
|
||||
func (*SysLoginLog) TableName() string {
|
||||
return "sys_login_log"
|
||||
}
|
||||
|
||||
func (e *SysLoginLog) Generate() models.ActiveRecord {
|
||||
o := *e
|
||||
return &o
|
||||
}
|
||||
|
||||
func (e *SysLoginLog) GetId() interface{} {
|
||||
return e.Id
|
||||
}
|
||||
|
||||
// SaveLoginLog 从队列中获取登录日志
|
||||
func SaveLoginLog(message storage.Messager) (err error) {
|
||||
//准备db
|
||||
db := sdk.Runtime.GetDbByKey(message.GetPrefix())
|
||||
if db == nil {
|
||||
err = errors.New("db not exist")
|
||||
log.Errorf("host[%s]'s %s", message.GetPrefix(), err.Error())
|
||||
return err
|
||||
}
|
||||
var rb []byte
|
||||
rb, err = json.Marshal(message.GetValues())
|
||||
if err != nil {
|
||||
log.Errorf("json Marshal error, %s", err.Error())
|
||||
return err
|
||||
}
|
||||
var l SysLoginLog
|
||||
err = json.Unmarshal(rb, &l)
|
||||
if err != nil {
|
||||
log.Errorf("json Unmarshal error, %s", err.Error())
|
||||
return err
|
||||
}
|
||||
err = db.Create(&l).Error
|
||||
if err != nil {
|
||||
log.Errorf("db create error, %s", err.Error())
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package models
|
||||
|
||||
import "go-admin/common/models"
|
||||
|
||||
type SysMenu struct {
|
||||
MenuId int `json:"menuId" gorm:"primaryKey;autoIncrement"`
|
||||
MenuName string `json:"menuName" gorm:"size:128;"`
|
||||
Title string `json:"title" gorm:"size:128;"`
|
||||
Icon string `json:"icon" gorm:"size:128;"`
|
||||
Path string `json:"path" gorm:"size:128;"`
|
||||
Paths string `json:"paths" gorm:"size:128;"`
|
||||
MenuType string `json:"menuType" gorm:"size:1;"`
|
||||
Action string `json:"action" gorm:"size:16;"`
|
||||
Permission string `json:"permission" gorm:"size:255;"`
|
||||
ParentId int `json:"parentId" gorm:"size:11;"`
|
||||
NoCache bool `json:"noCache" gorm:"size:8;"`
|
||||
Breadcrumb string `json:"breadcrumb" gorm:"size:255;"`
|
||||
Component string `json:"component" gorm:"size:255;"`
|
||||
Sort int `json:"sort" gorm:"size:4;"`
|
||||
Visible string `json:"visible" gorm:"size:1;"`
|
||||
IsFrame string `json:"isFrame" gorm:"size:1;DEFAULT:0;"`
|
||||
SysApi []SysApi `json:"sysApi" gorm:"many2many:sys_menu_api_rule"`
|
||||
Apis []int `json:"apis" gorm:"-"`
|
||||
DataScope string `json:"dataScope" gorm:"-"`
|
||||
Params string `json:"params" gorm:"-"`
|
||||
RoleId int `gorm:"-"`
|
||||
Children []SysMenu `json:"children,omitempty" gorm:"-"`
|
||||
IsSelect bool `json:"is_select" gorm:"-"`
|
||||
models.ControlBy
|
||||
models.ModelTime
|
||||
}
|
||||
|
||||
type SysMenuSlice []SysMenu
|
||||
|
||||
func (x SysMenuSlice) Len() int { return len(x) }
|
||||
func (x SysMenuSlice) Less(i, j int) bool { return x[i].Sort < x[j].Sort }
|
||||
func (x SysMenuSlice) Swap(i, j int) { x[i], x[j] = x[j], x[i] }
|
||||
|
||||
func (*SysMenu) TableName() string {
|
||||
return "sys_menu"
|
||||
}
|
||||
|
||||
func (e *SysMenu) Generate() models.ActiveRecord {
|
||||
o := *e
|
||||
return &o
|
||||
}
|
||||
|
||||
func (e *SysMenu) GetId() interface{} {
|
||||
return e.MenuId
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user